A React Native app for the ultimate thinking partner.

feat: Begin incremental refactor with documented component extraction

Phase 1 - UI Chrome Components:

AppHeader (✅ Complete):
- Extracted from App.tsx.monolithic lines 2083-2124
- Menu button, title, developer mode easter egg
- Full inline documentation of what it replaces
- Not yet integrated (zero risk)

BottomNavigation (✅ Complete):
- Extracted from App.tsx.monolithic lines 2126-2172
- 4 tabs: You, Chat, Knowledge, Settings
- Active state management
- Full inline documentation

MIGRATION_TRACKER.md:
- Comprehensive tracking of all components
- Maps each component to source lines
- Feature checklist for validation
- Testing strategy
- Success criteria
- Phase-by-phase plan

Strategy:
- Zero-risk extraction (old app still works)
- Build components alongside existing code
- Test with App.new.tsx before migration
- Never lose features
- Every component documents what it replaces

Next: Extract AppSidebar, then views

+4386 -47
+3768 -47
App.tsx
··· 1 - import React, { useState, useEffect } from 'react'; 2 - import { useColorScheme, ActivityIndicator, View, StyleSheet, Platform } from 'react-native'; 3 - import { SafeAreaProvider } from 'react-native-safe-area-context'; 1 + import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; 2 + import { 3 + View, 4 + Text, 5 + StyleSheet, 6 + TouchableOpacity, 7 + Alert, 8 + TextInput, 9 + FlatList, 10 + SafeAreaView, 11 + ActivityIndicator, 12 + Modal, 13 + Dimensions, 14 + useColorScheme, 15 + Platform, 16 + Linking, 17 + Animated, 18 + Image, 19 + KeyboardAvoidingView, 20 + ScrollView, 21 + Keyboard, 22 + } from 'react-native'; 23 + import { Ionicons } from '@expo/vector-icons'; 24 + import { StatusBar } from 'expo-status-bar'; 25 + import * as Clipboard from 'expo-clipboard'; 26 + import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; 4 27 import * as SystemUI from 'expo-system-ui'; 28 + import Markdown from '@ronradtke/react-native-markdown-display'; 29 + import * as ImagePicker from 'expo-image-picker'; 5 30 import { useFonts, Lexend_300Light, Lexend_400Regular, Lexend_500Medium, Lexend_600SemiBold, Lexend_700Bold } from '@expo-google-fonts/lexend'; 6 - import { StatusBar } from 'expo-status-bar'; 7 - 8 - // Components 9 - import { ErrorBoundary } from './src/components/ErrorBoundary'; 10 31 import LogoLoader from './src/components/LogoLoader'; 32 + import lettaApi from './src/api/lettaApi'; 33 + import Storage, { STORAGE_KEYS } from './src/utils/storage'; 34 + import { findOrCreateCo } from './src/utils/coAgent'; 11 35 import CoLoginScreen from './CoLoginScreen'; 12 - import { ChatScreen } from './src/screens/ChatScreen'; 13 - 14 - // Hooks 15 - import { useAuth } from './src/hooks/useAuth'; 16 - import { useAgent } from './src/hooks/useAgent'; 36 + import MessageContent from './src/components/MessageContent'; 37 + import ExpandableMessageContent from './src/components/ExpandableMessageContent'; 38 + import AnimatedStreamingText from './src/components/AnimatedStreamingText'; 39 + import ToolCallItem from './src/components/ToolCallItem'; 40 + import ReasoningToggle from './src/components/ReasoningToggle'; 41 + import LiveStatusIndicator from './src/components/LiveStatusIndicator'; 42 + import MemoryBlockViewer from './src/components/MemoryBlockViewer'; 43 + import MessageInput from './src/components/MessageInput'; 44 + import { createMarkdownStyles } from './src/components/markdownStyles'; 45 + import { darkTheme, lightTheme, CoColors } from './src/theme'; 46 + import type { LettaAgent, LettaMessage, StreamingChunk, MemoryBlock, Passage } from './src/types/letta'; 17 47 18 - // Theme 19 - import { darkTheme, lightTheme } from './src/theme'; 48 + // Import web styles for transparent input 49 + if (Platform.OS === 'web') { 50 + require('./web-styles.css'); 51 + } 20 52 21 53 function CoApp() { 54 + const insets = useSafeAreaInsets(); 22 55 const systemColorScheme = useColorScheme(); 23 56 const [colorScheme, setColorScheme] = useState<'light' | 'dark'>(systemColorScheme || 'dark'); 24 57 25 - // Load fonts 58 + // Set Android system UI colors 59 + useEffect(() => { 60 + if (Platform.OS === 'android') { 61 + SystemUI.setBackgroundColorAsync(darkTheme.colors.background.primary); 62 + } 63 + }, []); 64 + 65 + // Track keyboard state for Android 66 + const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); 67 + 68 + useEffect(() => { 69 + if (Platform.OS !== 'android') return; 70 + 71 + const showSubscription = Keyboard.addListener('keyboardDidShow', () => { 72 + setIsKeyboardVisible(true); 73 + }); 74 + const hideSubscription = Keyboard.addListener('keyboardDidHide', () => { 75 + setIsKeyboardVisible(false); 76 + }); 77 + 78 + return () => { 79 + showSubscription.remove(); 80 + hideSubscription.remove(); 81 + }; 82 + }, []); 83 + 26 84 const [fontsLoaded] = useFonts({ 27 85 Lexend_300Light, 28 86 Lexend_400Regular, ··· 31 89 Lexend_700Bold, 32 90 }); 33 91 34 - // Set Android system UI colors 92 + const toggleColorScheme = () => { 93 + setColorScheme(prev => prev === 'dark' ? 'light' : 'dark'); 94 + }; 95 + 96 + const theme = colorScheme === 'dark' ? darkTheme : lightTheme; 97 + 98 + // Authentication state 99 + const [apiToken, setApiToken] = useState(''); 100 + const [isConnected, setIsConnected] = useState(false); 101 + const [isConnecting, setIsConnecting] = useState(false); 102 + const [isLoadingToken, setIsLoadingToken] = useState(true); 103 + const [connectionError, setConnectionError] = useState<string | null>(null); 104 + 105 + // Co agent state 106 + const [coAgent, setCoAgent] = useState<LettaAgent | null>(null); 107 + const [isInitializingCo, setIsInitializingCo] = useState(false); 108 + const [isRefreshingCo, setIsRefreshingCo] = useState(false); 109 + 110 + // Message state 111 + const [messages, setMessages] = useState<LettaMessage[]>([]); 112 + 113 + // Debug logging for messages state changes 114 + useEffect(() => { 115 + console.log('[MESSAGES STATE] Changed, now have', messages.length, 'messages'); 116 + if (messages.length > 0) { 117 + console.log('[MESSAGES STATE] First:', messages[0]?.id?.substring(0, 8), messages[0]?.message_type); 118 + console.log('[MESSAGES STATE] Last:', messages[messages.length - 1]?.id?.substring(0, 8), messages[messages.length - 1]?.message_type); 119 + } 120 + }, [messages]); 121 + const PAGE_SIZE = 50; 122 + const INITIAL_LOAD_LIMIT = 100; // Increased to show more history by default 123 + const [earliestCursor, setEarliestCursor] = useState<string | null>(null); 124 + const [hasMoreBefore, setHasMoreBefore] = useState<boolean>(false); 125 + const [isLoadingMore, setIsLoadingMore] = useState<boolean>(false); 126 + const [selectedImages, setSelectedImages] = useState<Array<{ uri: string; base64: string; mediaType: string }>>([]); 127 + const [isSendingMessage, setIsSendingMessage] = useState(false); 128 + const [hasInputText, setHasInputText] = useState(false); 129 + const inputTextRef = useRef<string>(''); 130 + const [clearInputTrigger, setClearInputTrigger] = useState(0); 131 + const [isLoadingMessages, setIsLoadingMessages] = useState(false); 132 + 133 + // Simplified streaming state - everything in one place 134 + const [currentStream, setCurrentStream] = useState({ 135 + reasoning: '', 136 + toolCalls: [] as Array<{id: string, name: string, args: string}>, 137 + assistantMessage: '', 138 + }); 139 + // Store completed stream blocks (reasoning, assistant messages, tool calls that have finished) 140 + const [completedStreamBlocks, setCompletedStreamBlocks] = useState<Array<{ 141 + type: 'reasoning' | 'assistant_message', 142 + content: string 143 + }>>([]); 144 + const [isStreaming, setIsStreaming] = useState(false); 145 + const [lastMessageNeedsSpace, setLastMessageNeedsSpace] = useState(false); 146 + const spacerHeightAnim = useRef(new Animated.Value(0)).current; 147 + const rainbowAnimValue = useRef(new Animated.Value(0)).current; 148 + const [isInputFocused, setIsInputFocused] = useState(false); 149 + const statusFadeAnim = useRef(new Animated.Value(0)).current; 150 + const scrollIntervalRef = useRef<NodeJS.Timeout | null>(null); 151 + 152 + // HITL approval state 153 + const [approvalVisible, setApprovalVisible] = useState(false); 154 + const [approvalData, setApprovalData] = useState<{ 155 + id?: string; 156 + toolName?: string; 157 + toolArgs?: string; 158 + reasoning?: string; 159 + } | null>(null); 160 + const [approvalReason, setApprovalReason] = useState(''); 161 + const [isApproving, setIsApproving] = useState(false); 162 + 163 + // Layout state for responsive design 164 + const [screenData, setScreenData] = useState(Dimensions.get('window')); 165 + const [sidebarVisible, setSidebarVisible] = useState(false); 166 + const [activeSidebarTab, setActiveSidebarTab] = useState<'files'>('files'); 167 + const [currentView, setCurrentView] = useState<'you' | 'chat' | 'knowledge' | 'settings'>('chat'); 168 + const [knowledgeTab, setKnowledgeTab] = useState<'core' | 'archival' | 'files'>('core'); 169 + 170 + // Settings 171 + const [showCompaction, setShowCompaction] = useState(true); 172 + const [memoryBlocks, setMemoryBlocks] = useState<MemoryBlock[]>([]); 173 + const [isLoadingBlocks, setIsLoadingBlocks] = useState(false); 174 + const [blocksError, setBlocksError] = useState<string | null>(null); 175 + const [selectedBlock, setSelectedBlock] = useState<MemoryBlock | null>(null); 176 + const [memorySearchQuery, setMemorySearchQuery] = useState(''); 177 + const [youBlockContent, setYouBlockContent] = useState<string>(`## First Conversation 178 + 179 + I'm here to understand how you think and help you see what you know. 180 + 181 + As we talk, this space will evolve to reflect: 182 + - What you're focused on right now and why it matters 183 + - Patterns in how you approach problems 184 + - Connections between your ideas that might not be obvious 185 + - The questions you're holding 186 + 187 + I'm paying attention not just to what you say, but how you think. Let's start wherever feels natural.`); 188 + const [hasYouBlock, setHasYouBlock] = useState<boolean>(false); 189 + const [hasCheckedYouBlock, setHasCheckedYouBlock] = useState<boolean>(false); 190 + const [isCreatingYouBlock, setIsCreatingYouBlock] = useState<boolean>(false); 191 + const sidebarAnimRef = useRef(new Animated.Value(0)).current; 192 + const [developerMode, setDeveloperMode] = useState(true); 193 + const [headerClickCount, setHeaderClickCount] = useState(0); 194 + const headerClickTimeoutRef = useRef<NodeJS.Timeout | null>(null); 195 + 196 + // File management state 197 + const [coFolder, setCoFolder] = useState<any | null>(null); 198 + const [folderFiles, setFolderFiles] = useState<any[]>([]); 199 + const [isLoadingFiles, setIsLoadingFiles] = useState(false); 200 + 201 + // Archival memory state 202 + const [passages, setPassages] = useState<Passage[]>([]); 203 + const [isLoadingPassages, setIsLoadingPassages] = useState(false); 204 + const [passagesError, setPassagesError] = useState<string | null>(null); 205 + const [passageSearchQuery, setPassageSearchQuery] = useState(''); 206 + const [selectedPassage, setSelectedPassage] = useState<Passage | null>(null); 207 + const [isCreatingPassage, setIsCreatingPassage] = useState(false); 208 + const [isEditingPassage, setIsEditingPassage] = useState(false); 209 + const [isSavingPassage, setIsSavingPassage] = useState(false); 210 + const [passageAfterCursor, setPassageAfterCursor] = useState<string | undefined>(undefined); 211 + const [hasMorePassages, setHasMorePassages] = useState(false); 212 + const [isUploadingFile, setIsUploadingFile] = useState(false); 213 + const [uploadProgress, setUploadProgress] = useState<string>(''); 214 + const [filesError, setFilesError] = useState<string | null>(null); 215 + 216 + const isDesktop = screenData.width >= 768; 217 + 218 + // Ref for ScrollView to control scrolling 219 + const scrollViewRef = useRef<FlatList<any>>(null); 220 + const [scrollY, setScrollY] = useState(0); 221 + const [contentHeight, setContentHeight] = useState(0); 222 + const [containerHeight, setContainerHeight] = useState(0); 223 + const [showScrollToBottom, setShowScrollToBottom] = useState(false); 224 + const [inputContainerHeight, setInputContainerHeight] = useState(0); 225 + const pendingJumpToBottomRef = useRef<boolean>(false); 226 + const pendingJumpRetriesRef = useRef<number>(0); 227 + const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null); 228 + 229 + // Load stored API token on mount 230 + useEffect(() => { 231 + loadStoredToken(); 232 + }, []); 233 + 234 + // Cleanup intervals on unmount 235 + useEffect(() => { 236 + return () => { 237 + if (scrollIntervalRef.current) { 238 + clearInterval(scrollIntervalRef.current); 239 + } 240 + }; 241 + }, []); 242 + 243 + // Initialize Co when connected 244 + useEffect(() => { 245 + if (isConnected && !coAgent && !isInitializingCo) { 246 + initializeCo(); 247 + } 248 + }, [isConnected, coAgent, isInitializingCo]); 249 + 250 + // Load messages when Co agent is ready 35 251 useEffect(() => { 36 - if (Platform.OS === 'android') { 37 - SystemUI.setBackgroundColorAsync(darkTheme.colors.background.primary); 252 + if (coAgent) { 253 + loadMessages(); 254 + } 255 + }, [coAgent]); 256 + 257 + 258 + const loadStoredToken = async () => { 259 + try { 260 + const stored = await Storage.getItem(STORAGE_KEYS.API_TOKEN); 261 + if (stored) { 262 + setApiToken(stored); 263 + await connectWithToken(stored); 264 + } 265 + } catch (error) { 266 + console.error('Failed to load stored token:', error); 267 + } finally { 268 + setIsLoadingToken(false); 269 + } 270 + }; 271 + 272 + const connectWithToken = async (token: string) => { 273 + setIsConnecting(true); 274 + setConnectionError(null); 275 + try { 276 + lettaApi.setAuthToken(token); 277 + const isValid = await lettaApi.testConnection(); 278 + 279 + if (isValid) { 280 + setIsConnected(true); 281 + await Storage.setItem(STORAGE_KEYS.API_TOKEN, token); 282 + } else { 283 + throw new Error('Invalid API token'); 284 + } 285 + } catch (error: any) { 286 + console.error('Connection failed:', error); 287 + setConnectionError(error.message || 'Failed to connect'); 288 + lettaApi.removeAuthToken(); 289 + setIsConnected(false); 290 + } finally { 291 + setIsConnecting(false); 292 + } 293 + }; 294 + 295 + const handleLogin = async (token: string) => { 296 + setApiToken(token); 297 + await connectWithToken(token); 298 + }; 299 + 300 + const handleLogout = async () => { 301 + Alert.alert( 302 + 'Logout', 303 + 'Are you sure you want to log out?', 304 + [ 305 + { text: 'Cancel', style: 'cancel' }, 306 + { 307 + text: 'Logout', 308 + style: 'destructive', 309 + onPress: async () => { 310 + await Storage.removeItem(STORAGE_KEYS.API_TOKEN); 311 + lettaApi.removeAuthToken(); 312 + setApiToken(''); 313 + setIsConnected(false); 314 + setCoAgent(null); 315 + setMessages([]); 316 + setConnectionError(null); 317 + }, 318 + }, 319 + ] 320 + ); 321 + }; 322 + 323 + const initializeCo = async () => { 324 + setIsInitializingCo(true); 325 + try { 326 + console.log('Initializing Co agent...'); 327 + const agent = await findOrCreateCo('User'); 328 + setCoAgent(agent); 329 + console.log('=== CO AGENT INITIALIZED ==='); 330 + console.log('Co agent ID:', agent.id); 331 + console.log('Co agent name:', agent.name); 332 + console.log('Co agent LLM config:', JSON.stringify(agent.llmConfig, null, 2)); 333 + console.log('LLM model:', agent.llmConfig?.model); 334 + console.log('LLM context window:', agent.llmConfig?.contextWindow); 335 + } catch (error: any) { 336 + console.error('Failed to initialize Co:', error); 337 + Alert.alert('Error', 'Failed to initialize Co: ' + (error.message || 'Unknown error')); 338 + } finally { 339 + setIsInitializingCo(false); 340 + } 341 + }; 342 + 343 + // Helper function to filter out any message in the top 5 that contains "More human than human" 344 + const filterFirstMessage = (msgs: LettaMessage[]): LettaMessage[] => { 345 + const checkLimit = Math.min(5, msgs.length); 346 + for (let i = 0; i < checkLimit; i++) { 347 + if (msgs[i].content.includes('More human than human')) { 348 + return [...msgs.slice(0, i), ...msgs.slice(i + 1)]; 349 + } 350 + } 351 + return msgs; 352 + }; 353 + 354 + const loadMessages = async (before?: string, limit?: number) => { 355 + if (!coAgent) return; 356 + 357 + try { 358 + if (!before) { 359 + setIsLoadingMessages(true); 360 + } else { 361 + setIsLoadingMore(true); 362 + } 363 + 364 + const loadedMessages = await lettaApi.listMessages(coAgent.id, { 365 + before: before || undefined, 366 + limit: limit || (before ? PAGE_SIZE : INITIAL_LOAD_LIMIT), 367 + use_assistant_message: true, 368 + }); 369 + 370 + console.log('[LOAD MESSAGES] Received', loadedMessages.length, 'messages from server'); 371 + console.log('[LOAD MESSAGES] First message:', loadedMessages[0]?.id, loadedMessages[0]?.message_type); 372 + console.log('[LOAD MESSAGES] Last message:', loadedMessages[loadedMessages.length - 1]?.id, loadedMessages[loadedMessages.length - 1]?.message_type); 373 + 374 + if (loadedMessages.length > 0) { 375 + if (before) { 376 + const filtered = filterFirstMessage([...loadedMessages, ...prev]); 377 + console.log('[LOAD MESSAGES] After filtering (load more):', filtered.length); 378 + setMessages(prev => filterFirstMessage([...loadedMessages, ...prev])); 379 + setEarliestCursor(loadedMessages[0].id); 380 + } else { 381 + const filtered = filterFirstMessage(loadedMessages); 382 + console.log('[LOAD MESSAGES] After filtering (initial load):', filtered.length, 'from', loadedMessages.length); 383 + setMessages(filtered); 384 + if (loadedMessages.length > 0) { 385 + setEarliestCursor(loadedMessages[0].id); 386 + pendingJumpToBottomRef.current = true; 387 + pendingJumpRetriesRef.current = 3; 388 + // Immediately scroll to bottom without animation on initial load 389 + setTimeout(() => { 390 + scrollViewRef.current?.scrollToEnd({ animated: false }); 391 + }, 100); 392 + } 393 + } 394 + setHasMoreBefore(loadedMessages.length === (limit || (before ? PAGE_SIZE : INITIAL_LOAD_LIMIT))); 395 + } else if (before) { 396 + // No more messages to load before 397 + setHasMoreBefore(false); 398 + } 399 + // If no messages and not loading before, keep existing messages (don't clear) 400 + } catch (error: any) { 401 + console.error('Failed to load messages:', error); 402 + Alert.alert('Error', 'Failed to load messages: ' + (error.message || 'Unknown error')); 403 + } finally { 404 + setIsLoadingMessages(false); 405 + setIsLoadingMore(false); 406 + } 407 + }; 408 + 409 + const loadMoreMessages = () => { 410 + if (hasMoreBefore && !isLoadingMore && earliestCursor) { 411 + loadMessages(earliestCursor); 412 + } 413 + }; 414 + 415 + const copyToClipboard = useCallback(async (content: string, messageId?: string) => { 416 + try { 417 + await Clipboard.setStringAsync(content); 418 + if (messageId) { 419 + setCopiedMessageId(messageId); 420 + setTimeout(() => setCopiedMessageId(null), 2000); 421 + } 422 + } catch (error) { 423 + console.error('Failed to copy to clipboard:', error); 38 424 } 39 425 }, []); 40 426 41 - // Use hooks for state management 42 - const { 43 - isConnected, 44 - isLoadingToken, 45 - isConnecting, 46 - connectionError, 47 - connectWithToken, 48 - } = useAuth(); 427 + const pickImage = async () => { 428 + try { 429 + // Request permissions 430 + const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); 431 + if (status !== 'granted') { 432 + Alert.alert('Permission Required', 'Please allow access to your photo library to upload images.'); 433 + return; 434 + } 435 + 436 + // Launch image picker 437 + const result = await ImagePicker.launchImageLibraryAsync({ 438 + mediaTypes: ['images'], 439 + allowsMultipleSelection: false, 440 + quality: 0.8, 441 + base64: true, 442 + }); 443 + 444 + console.log('Image picker result:', { canceled: result.canceled, assetsCount: result.assets?.length }); 445 + 446 + if (!result.canceled && result.assets && result.assets.length > 0) { 447 + const asset = result.assets[0]; 448 + console.log('Asset info:', { 449 + hasBase64: !!asset.base64, 450 + base64Length: asset.base64?.length, 451 + uri: asset.uri 452 + }); 49 453 50 - const { coAgent, isInitializingCo } = useAgent(); 454 + if (asset.base64) { 455 + // Check size: 5MB = 5 * 1024 * 1024 bytes 456 + const MAX_SIZE = 5 * 1024 * 1024; 457 + const sizeMB = (asset.base64.length / 1024 / 1024).toFixed(2); 458 + console.log(`Image size: ${sizeMB}MB, max allowed: 5MB`); 51 459 52 - const theme = colorScheme === 'dark' ? darkTheme : lightTheme; 460 + if (asset.base64.length > MAX_SIZE) { 461 + console.error(`IMAGE REJECTED: ${sizeMB}MB exceeds 5MB limit`); 462 + Alert.alert( 463 + 'Image Too Large', 464 + `This image is ${sizeMB}MB, but the maximum allowed size is 5MB. Please select a smaller image or compress it first.` 465 + ); 466 + return; // Discard the image 467 + } 53 468 54 - // Show loading screen while fonts load or token is being loaded 55 - if (!fontsLoaded || isLoadingToken) { 56 - return <LogoLoader />; 469 + const mediaType = asset.uri.match(/\.(jpg|jpeg)$/i) ? 'image/jpeg' : 470 + asset.uri.match(/\.png$/i) ? 'image/png' : 471 + asset.uri.match(/\.gif$/i) ? 'image/gif' : 472 + asset.uri.match(/\.webp$/i) ? 'image/webp' : 'image/jpeg'; 473 + 474 + console.log('Adding image with mediaType:', mediaType); 475 + setSelectedImages(prev => [...prev, { 476 + uri: asset.uri, 477 + base64: asset.base64, 478 + mediaType, 479 + }]); 480 + } else { 481 + console.error('No base64 data in asset'); 482 + Alert.alert('Error', 'Failed to read image data'); 483 + } 484 + } else { 485 + console.log('Image picker canceled or no assets'); 486 + } 487 + } catch (error) { 488 + console.error('Error picking image:', error); 489 + Alert.alert('Error', 'Failed to pick image'); 490 + } 491 + }; 492 + 493 + const removeImage = (index: number) => { 494 + setSelectedImages(prev => prev.filter((_, i) => i !== index)); 495 + }; 496 + 497 + const handleStreamingChunk = useCallback((chunk: StreamingChunk) => { 498 + console.log('Streaming chunk:', chunk.message_type, 'content:', chunk.content, 'reasoning:', chunk.reasoning); 499 + 500 + // Handle error chunks 501 + if ((chunk as any).error) { 502 + console.error('Error chunk received:', (chunk as any).error); 503 + setIsStreaming(false); 504 + setIsSendingMessage(false); 505 + setCurrentStream({ reasoning: '', toolCalls: [], assistantMessage: '' }); 506 + setCompletedStreamBlocks([]); 507 + return; 508 + } 509 + 510 + // Handle stop_reason chunks 511 + if ((chunk as any).message_type === 'stop_reason') { 512 + console.log('Stop reason received:', (chunk as any).stopReason || (chunk as any).stop_reason); 513 + return; 514 + } 515 + 516 + // Accumulate content - when switching message types, save previous content to completed blocks 517 + if (chunk.message_type === 'reasoning_message' && chunk.reasoning) { 518 + setCurrentStream(prev => { 519 + // If we have assistant message, this is a NEW reasoning block - save assistant message first 520 + if (prev.assistantMessage) { 521 + setCompletedStreamBlocks(blocks => [...blocks, { 522 + type: 'assistant_message', 523 + content: prev.assistantMessage 524 + }]); 525 + return { 526 + reasoning: chunk.reasoning, 527 + toolCalls: [], 528 + assistantMessage: '' 529 + }; 530 + } 531 + // Otherwise, continue accumulating reasoning 532 + return { 533 + ...prev, 534 + reasoning: prev.reasoning + chunk.reasoning 535 + }; 536 + }); 537 + } else if ((chunk.message_type === 'tool_call_message' || chunk.message_type === 'tool_call') && chunk.tool_call) { 538 + // Add tool call to list (deduplicate by ID) 539 + const callObj = chunk.tool_call.function || chunk.tool_call; 540 + const toolName = callObj?.name || callObj?.tool_name || 'tool'; 541 + const args = callObj?.arguments || callObj?.args || {}; 542 + const toolCallId = chunk.id || `tool_${toolName}_${Date.now()}`; 543 + 544 + const formatArgsPython = (obj: any): string => { 545 + if (!obj || typeof obj !== 'object') return ''; 546 + return Object.entries(obj) 547 + .map(([k, v]) => `${k}=${typeof v === 'string' ? `"${v}"` : JSON.stringify(v)}`) 548 + .join(', '); 549 + }; 550 + 551 + const toolLine = `${toolName}(${formatArgsPython(args)})`; 552 + 553 + setCurrentStream(prev => { 554 + // Check if this tool call ID already exists 555 + const exists = prev.toolCalls.some(tc => tc.id === toolCallId); 556 + if (exists) { 557 + return prev; // Don't add duplicates 558 + } 559 + return { 560 + ...prev, 561 + toolCalls: [...prev.toolCalls, { id: toolCallId, name: toolName, args: toolLine }] 562 + }; 563 + }); 564 + } else if (chunk.message_type === 'assistant_message' && chunk.content) { 565 + // Accumulate assistant message 566 + let contentText = ''; 567 + const content = chunk.content as any; 568 + if (typeof content === 'string') { 569 + contentText = content; 570 + } else if (typeof content === 'object' && content !== null) { 571 + if (Array.isArray(content)) { 572 + contentText = content 573 + .filter((item: any) => item.type === 'text') 574 + .map((item: any) => item.text || '') 575 + .join(''); 576 + } else if (content.text) { 577 + contentText = content.text; 578 + } 579 + } 580 + 581 + if (contentText) { 582 + setCurrentStream(prev => { 583 + // If we have reasoning, this is a NEW assistant message - save reasoning first 584 + if (prev.reasoning && !prev.assistantMessage) { 585 + setCompletedStreamBlocks(blocks => [...blocks, { 586 + type: 'reasoning', 587 + content: prev.reasoning 588 + }]); 589 + return { 590 + reasoning: '', 591 + toolCalls: [], 592 + assistantMessage: contentText 593 + }; 594 + } 595 + // Otherwise, continue accumulating assistant message 596 + return { 597 + ...prev, 598 + assistantMessage: prev.assistantMessage + contentText 599 + }; 600 + }); 601 + } 602 + } else if (chunk.message_type === 'tool_return_message' || chunk.message_type === 'tool_response') { 603 + // Ignore tool returns during streaming - we'll get them from server 604 + return; 605 + } else if (chunk.message_type === 'approval_request_message') { 606 + // Handle approval request 607 + const callObj = chunk.tool_call?.function || chunk.tool_call; 608 + setApprovalData({ 609 + id: chunk.id, 610 + toolName: callObj?.name || callObj?.tool_name, 611 + toolArgs: callObj?.arguments || callObj?.args, 612 + reasoning: chunk.reasoning, 613 + }); 614 + setApprovalVisible(true); 615 + } 616 + }, []); 617 + 618 + const sendMessage = useCallback(async (messageText: string, imagesToSend: Array<{ uri: string; base64: string; mediaType: string }>) => { 619 + if ((!messageText.trim() && imagesToSend.length === 0) || !coAgent || isSendingMessage) return; 620 + 621 + console.log('sendMessage called - messageText:', messageText, 'type:', typeof messageText, 'imagesToSend length:', imagesToSend.length); 622 + 623 + setIsSendingMessage(true); 624 + 625 + // Immediately add user message to UI (with images if any) 626 + let tempMessageContent: any; 627 + if (imagesToSend.length > 0) { 628 + const contentParts = []; 629 + 630 + // Add images using base64 (SDK expects camelCase, converts to snake_case for HTTP) 631 + for (const img of imagesToSend) { 632 + contentParts.push({ 633 + type: 'image', 634 + source: { 635 + type: 'base64', 636 + mediaType: img.mediaType, 637 + data: img.base64, 638 + }, 639 + }); 640 + } 641 + 642 + // Add text if present 643 + console.log('[TEMP] About to check text - messageText:', JSON.stringify(messageText), 'type:', typeof messageText, 'length:', messageText?.length); 644 + if (messageText && typeof messageText === 'string' && messageText.length > 0) { 645 + console.log('[TEMP] Adding text to contentParts'); 646 + contentParts.push({ 647 + type: 'text', 648 + text: messageText, 649 + }); 650 + } 651 + 652 + console.log('[TEMP] Final contentParts:', JSON.stringify(contentParts)); 653 + tempMessageContent = contentParts; 654 + } else { 655 + tempMessageContent = messageText; 656 + } 657 + 658 + const tempUserMessage: LettaMessage = { 659 + id: `temp-${Date.now()}`, 660 + role: 'user', 661 + message_type: 'user_message', 662 + content: tempMessageContent, 663 + created_at: new Date().toISOString(), 664 + } as LettaMessage; 665 + 666 + console.log('[USER MESSAGE] Adding temp user message:', tempUserMessage.id, 'content type:', typeof tempUserMessage.content); 667 + setMessages(prev => { 668 + const newMessages = [...prev, tempUserMessage]; 669 + console.log('[USER MESSAGE] Messages count after add:', newMessages.length); 670 + return newMessages; 671 + }); 672 + 673 + // Scroll to bottom immediately to show user message 674 + setTimeout(() => { 675 + scrollViewRef.current?.scrollToEnd({ animated: false }); 676 + }, 50); 677 + 678 + try { 679 + setIsStreaming(true); 680 + setLastMessageNeedsSpace(true); 681 + // Clear streaming state 682 + setCurrentStream({ reasoning: '', toolCalls: [], assistantMessage: '' }); 683 + setCompletedStreamBlocks([]); 684 + 685 + // Make status indicator immediately visible 686 + statusFadeAnim.setValue(1); 687 + 688 + // Animate spacer growing to push user message up (push previous content out of view) 689 + const targetHeight = Math.max(containerHeight * 0.9, 450); 690 + spacerHeightAnim.setValue(0); 691 + 692 + Animated.timing(spacerHeightAnim, { 693 + toValue: targetHeight, 694 + duration: 400, 695 + useNativeDriver: false, // height animation can't use native driver 696 + }).start(); 697 + 698 + // During animation, keep scroll at bottom 699 + if (scrollIntervalRef.current) { 700 + clearInterval(scrollIntervalRef.current); 701 + } 702 + scrollIntervalRef.current = setInterval(() => { 703 + scrollViewRef.current?.scrollToEnd({ animated: false }); 704 + }, 16); // ~60fps 705 + 706 + setTimeout(() => { 707 + if (scrollIntervalRef.current) { 708 + clearInterval(scrollIntervalRef.current); 709 + scrollIntervalRef.current = null; 710 + } 711 + }, 400); 712 + 713 + // Build message content based on whether we have images 714 + let messageContent: any; 715 + if (imagesToSend.length > 0) { 716 + // Multi-part content with images 717 + const contentParts = []; 718 + 719 + // Add images using base64 (SDK expects camelCase, converts to snake_case for HTTP) 720 + for (const img of imagesToSend) { 721 + console.log('Adding image - mediaType:', img.mediaType, 'base64 length:', img.base64?.length); 722 + 723 + contentParts.push({ 724 + type: 'image', 725 + source: { 726 + type: 'base64', 727 + mediaType: img.mediaType, 728 + data: img.base64, 729 + }, 730 + }); 731 + } 732 + 733 + // Add text if present 734 + console.log('[API] About to check text - messageText:', JSON.stringify(messageText), 'type:', typeof messageText); 735 + if (messageText && typeof messageText === 'string' && messageText.length > 0) { 736 + console.log('[API] Adding text to contentParts - text value:', messageText); 737 + const textItem = { 738 + type: 'text' as const, 739 + text: String(messageText), // Explicitly convert to string as safeguard 740 + }; 741 + console.log('[API] Text item to push:', JSON.stringify(textItem)); 742 + contentParts.push(textItem); 743 + } 744 + 745 + messageContent = contentParts; 746 + console.log('Built contentParts:', contentParts.length, 'items'); 747 + console.log('Full message structure:', JSON.stringify({role: 'user', content: messageContent}, null, 2).substring(0, 1000)); 748 + } else { 749 + // Text-only message 750 + messageContent = messageText; 751 + console.log('Sending text-only message:', messageText); 752 + } 753 + 754 + console.log('=== ABOUT TO SEND TO API ==='); 755 + console.log('messageContent type:', typeof messageContent); 756 + console.log('messageContent is array?', Array.isArray(messageContent)); 757 + console.log('messageContent:', JSON.stringify(messageContent, null, 2)); 758 + 759 + const payload = { 760 + messages: [{ role: 'user', content: messageContent }], 761 + use_assistant_message: true, 762 + stream_tokens: true, 763 + }; 764 + 765 + console.log('Full payload being sent:', JSON.stringify(payload, null, 2).substring(0, 2000)); 766 + 767 + await lettaApi.sendMessageStream( 768 + coAgent.id, 769 + payload, 770 + (chunk: StreamingChunk) => { 771 + handleStreamingChunk(chunk); 772 + }, 773 + async (response) => { 774 + console.log('Stream complete'); 775 + console.log('[STREAM COMPLETE] Fetching finalized messages from server'); 776 + 777 + // Reset spacer animation immediately to remove the gap 778 + spacerHeightAnim.setValue(0); 779 + 780 + // Wait for server to finalize messages 781 + setTimeout(async () => { 782 + try { 783 + // Use setMessages callback to get current state and calculate fetch limit 784 + let fetchLimit = 100; // Default minimum increased to be safer 785 + 786 + setMessages(prev => { 787 + const currentCount = prev.filter(msg => !msg.id.startsWith('temp-')).length; 788 + fetchLimit = Math.max(currentCount + 10, 100); // Fetch more buffer 789 + console.log('[STREAM COMPLETE] Current message count:', currentCount, 'Will fetch:', fetchLimit); 790 + return prev; // Don't change messages yet 791 + }); 792 + 793 + // Fetch recent messages with enough limit to cover what we had plus new ones 794 + const recentMessages = await lettaApi.listMessages(coAgent.id, { 795 + limit: fetchLimit, 796 + use_assistant_message: true, 797 + }); 798 + 799 + console.log('[STREAM COMPLETE] Received', recentMessages.length, 'messages from server'); 800 + console.log('[STREAM COMPLETE] First message ID:', recentMessages[0]?.id); 801 + console.log('[STREAM COMPLETE] Last message ID:', recentMessages[recentMessages.length - 1]?.id); 802 + 803 + if (recentMessages.length > 0) { 804 + // Replace messages entirely with server response (this removes temp messages) 805 + setMessages(filterFirstMessage(recentMessages)); 806 + console.log('[STREAM COMPLETE] Updated messages state'); 807 + } 808 + } catch (error) { 809 + console.error('Failed to fetch finalized messages:', error); 810 + } finally { 811 + // Clear streaming state after attempting to load 812 + setIsStreaming(false); 813 + setCurrentStream({ reasoning: '', toolCalls: [], assistantMessage: '' }); 814 + setCompletedStreamBlocks([]); 815 + } 816 + }, 500); 817 + }, 818 + (error) => { 819 + console.error('=== APP STREAMING ERROR CALLBACK ==='); 820 + console.error('Streaming error:', error); 821 + console.error('Error type:', typeof error); 822 + console.error('Error keys:', Object.keys(error || {})); 823 + console.error('Error details:', { 824 + message: error?.message, 825 + status: error?.status, 826 + code: error?.code, 827 + response: error?.response, 828 + responseData: error?.responseData 829 + }); 830 + 831 + // Try to log full error structure 832 + try { 833 + console.error('Full error JSON:', JSON.stringify(error, null, 2)); 834 + } catch (e) { 835 + console.error('Could not stringify error:', e); 836 + } 837 + 838 + // Clear scroll interval on error 839 + if (scrollIntervalRef.current) { 840 + clearInterval(scrollIntervalRef.current); 841 + scrollIntervalRef.current = null; 842 + } 843 + 844 + // Reset spacer animation 845 + spacerHeightAnim.setValue(0); 846 + 847 + setIsStreaming(false); 848 + setCurrentStream({ reasoning: '', toolCalls: [], assistantMessage: '' }); 849 + setCompletedStreamBlocks([]); 850 + 851 + // Create detailed error message 852 + let errorMsg = 'Failed to send message'; 853 + if (error?.message) { 854 + errorMsg += ': ' + error.message; 855 + } 856 + if (error?.status) { 857 + errorMsg += ' (Status: ' + error.status + ')'; 858 + } 859 + if (error?.responseData) { 860 + try { 861 + const responseStr = typeof error.responseData === 'string' 862 + ? error.responseData 863 + : JSON.stringify(error.responseData); 864 + errorMsg += '\nDetails: ' + responseStr; 865 + } catch (e) { 866 + // ignore 867 + } 868 + } 869 + 870 + Alert.alert('Error', errorMsg); 871 + } 872 + ); 873 + } catch (error: any) { 874 + console.error('=== APP SEND MESSAGE OUTER CATCH ==='); 875 + console.error('Failed to send message:', error); 876 + console.error('Error type:', typeof error); 877 + console.error('Error keys:', Object.keys(error || {})); 878 + console.error('Error details:', { 879 + message: error?.message, 880 + status: error?.status, 881 + code: error?.code, 882 + response: error?.response, 883 + responseData: error?.responseData 884 + }); 885 + 886 + try { 887 + console.error('Full error JSON:', JSON.stringify(error, null, 2)); 888 + } catch (e) { 889 + console.error('Could not stringify error:', e); 890 + } 891 + 892 + Alert.alert('Error', 'Failed to send message: ' + (error.message || 'Unknown error')); 893 + setIsStreaming(false); 894 + spacerHeightAnim.setValue(0); 895 + } finally { 896 + setIsSendingMessage(false); 897 + } 898 + }, [coAgent, isSendingMessage, containerHeight, spacerHeightAnim, handleStreamingChunk]); 899 + 900 + const handleTextChange = useCallback((text: string) => { 901 + inputTextRef.current = text; 902 + const hasText = text.trim().length > 0; 903 + // Only update state when crossing the empty/non-empty boundary 904 + setHasInputText(prev => prev !== hasText ? hasText : prev); 905 + }, []); 906 + 907 + const handleSendFromInput = useCallback(() => { 908 + const text = inputTextRef.current.trim(); 909 + if (text || selectedImages.length > 0) { 910 + sendMessage(text, selectedImages); 911 + setSelectedImages([]); 912 + inputTextRef.current = ''; 913 + setHasInputText(false); 914 + setClearInputTrigger(prev => prev + 1); 915 + } 916 + }, [sendMessage, selectedImages]); 917 + 918 + const handleApproval = async (approve: boolean) => { 919 + if (!approvalData?.id || !coAgent) return; 920 + 921 + setIsApproving(true); 922 + try { 923 + await lettaApi.approveToolRequest(coAgent.id, { 924 + approval_request_id: approvalData.id, 925 + approve, 926 + reason: approvalReason || undefined, 927 + }); 928 + 929 + setApprovalVisible(false); 930 + setApprovalData(null); 931 + setApprovalReason(''); 932 + 933 + // Continue streaming after approval 934 + } catch (error: any) { 935 + console.error('Approval error:', error); 936 + Alert.alert('Error', 'Failed to process approval: ' + (error.message || 'Unknown error')); 937 + } finally { 938 + setIsApproving(false); 939 + } 940 + }; 941 + 942 + const loadMemoryBlocks = async () => { 943 + if (!coAgent) return; 944 + 945 + setIsLoadingBlocks(true); 946 + setBlocksError(null); 947 + try { 948 + const blocks = await lettaApi.listAgentBlocks(coAgent.id); 949 + setMemoryBlocks(blocks); 950 + 951 + // Extract the "you" block for the You view 952 + const youBlock = blocks.find(block => block.label === 'you'); 953 + if (youBlock) { 954 + setYouBlockContent(youBlock.value); 955 + setHasYouBlock(true); 956 + } else { 957 + setHasYouBlock(false); 958 + } 959 + setHasCheckedYouBlock(true); 960 + } catch (error: any) { 961 + console.error('Failed to load memory blocks:', error); 962 + setBlocksError(error.message || 'Failed to load memory blocks'); 963 + setHasCheckedYouBlock(true); 964 + } finally { 965 + setIsLoadingBlocks(false); 966 + } 967 + }; 968 + 969 + const createYouBlock = async () => { 970 + if (!coAgent) return; 971 + 972 + setIsCreatingYouBlock(true); 973 + try { 974 + const { YOU_BLOCK } = await import('./src/constants/memoryBlocks'); 975 + const createdBlock = await lettaApi.createAgentBlock(coAgent.id, { 976 + label: YOU_BLOCK.label, 977 + value: YOU_BLOCK.value, 978 + description: YOU_BLOCK.description, 979 + limit: YOU_BLOCK.limit, 980 + }); 981 + 982 + // Update state 983 + setYouBlockContent(createdBlock.value); 984 + setHasYouBlock(true); 985 + setMemoryBlocks(prev => [...prev, createdBlock]); 986 + } catch (error: any) { 987 + console.error('Failed to create you block:', error); 988 + Alert.alert('Error', error.message || 'Failed to create you block'); 989 + } finally { 990 + setIsCreatingYouBlock(false); 991 + } 992 + }; 993 + 994 + // Archival Memory (Passages) functions 995 + const loadPassages = async (resetCursor = false) => { 996 + if (!coAgent) return; 997 + 998 + setIsLoadingPassages(true); 999 + setPassagesError(null); 1000 + try { 1001 + const params: any = { 1002 + limit: 50, 1003 + }; 1004 + 1005 + if (!resetCursor && passageAfterCursor) { 1006 + params.after = passageAfterCursor; 1007 + } 1008 + 1009 + if (passageSearchQuery) { 1010 + params.search = passageSearchQuery; 1011 + } 1012 + 1013 + // Use primary agent for archival memory 1014 + const result = await lettaApi.listPassages(coAgent.id, params); 1015 + 1016 + if (resetCursor) { 1017 + setPassages(result); 1018 + } else { 1019 + setPassages(prev => [...prev, ...result]); 1020 + } 1021 + 1022 + setHasMorePassages(result.length === 50); 1023 + if (result.length > 0) { 1024 + setPassageAfterCursor(result[result.length - 1].id); 1025 + } 1026 + } catch (error: any) { 1027 + console.error('Failed to load passages:', error); 1028 + setPassagesError(error.message || 'Failed to load passages'); 1029 + } finally { 1030 + setIsLoadingPassages(false); 1031 + } 1032 + }; 1033 + 1034 + const createPassage = async (text: string, tags?: string[]) => { 1035 + if (!coAgent) return; 1036 + 1037 + setIsLoadingPassages(true); 1038 + try { 1039 + // Use primary agent for archival memory 1040 + await lettaApi.createPassage(coAgent.id, { text, tags }); 1041 + await loadPassages(true); 1042 + Alert.alert('Success', 'Passage created successfully'); 1043 + } catch (error: any) { 1044 + console.error('Failed to create passage:', error); 1045 + Alert.alert('Error', error.message || 'Failed to create passage'); 1046 + } finally { 1047 + setIsLoadingPassages(false); 1048 + } 1049 + }; 1050 + 1051 + const deletePassage = async (passageId: string) => { 1052 + if (!coAgent) return; 1053 + 1054 + const confirmed = Platform.OS === 'web' 1055 + ? window.confirm('Are you sure you want to delete this passage?') 1056 + : await new Promise<boolean>((resolve) => { 1057 + Alert.alert( 1058 + 'Delete Passage', 1059 + 'Are you sure you want to delete this passage?', 1060 + [ 1061 + { text: 'Cancel', style: 'cancel', onPress: () => resolve(false) }, 1062 + { text: 'Delete', style: 'destructive', onPress: () => resolve(true) }, 1063 + ] 1064 + ); 1065 + }); 1066 + 1067 + if (!confirmed) return; 1068 + 1069 + try { 1070 + // Use primary agent for archival memory 1071 + await lettaApi.deletePassage(coAgent.id, passageId); 1072 + await loadPassages(true); 1073 + if (Platform.OS === 'web') { 1074 + window.alert('Passage deleted successfully'); 1075 + } else { 1076 + Alert.alert('Success', 'Passage deleted'); 1077 + } 1078 + } catch (error: any) { 1079 + console.error('Delete passage error:', error); 1080 + if (Platform.OS === 'web') { 1081 + window.alert('Failed to delete passage: ' + (error.message || 'Unknown error')); 1082 + } else { 1083 + Alert.alert('Error', 'Failed to delete passage: ' + (error.message || 'Unknown error')); 1084 + } 1085 + } 1086 + }; 1087 + 1088 + const modifyPassage = async (passageId: string, text: string, tags?: string[]) => { 1089 + if (!coAgent) return; 1090 + 1091 + setIsLoadingPassages(true); 1092 + try { 1093 + // Use primary agent for archival memory 1094 + await lettaApi.modifyPassage(coAgent.id, passageId, { text, tags }); 1095 + await loadPassages(true); 1096 + Alert.alert('Success', 'Passage updated successfully'); 1097 + } catch (error: any) { 1098 + console.error('Failed to modify passage:', error); 1099 + Alert.alert('Error', error.message || 'Failed to modify passage'); 1100 + } finally { 1101 + setIsLoadingPassages(false); 1102 + } 1103 + }; 1104 + 1105 + const initializeCoFolder = async () => { 1106 + if (!coAgent) return; 1107 + 1108 + try { 1109 + console.log('Initializing co folder...'); 1110 + 1111 + let folder: any = null; 1112 + 1113 + // First, try to get cached folder ID 1114 + const cachedFolderId = await Storage.getItem(STORAGE_KEYS.CO_FOLDER_ID); 1115 + if (cachedFolderId) { 1116 + console.log('Found cached folder ID:', cachedFolderId); 1117 + try { 1118 + // Try to get the folder by ID directly (we'll need to add this method) 1119 + const folders = await lettaApi.listFolders({ name: 'co-app' }); 1120 + folder = folders.find(f => f.id === cachedFolderId); 1121 + if (folder) { 1122 + console.log('Using cached folder:', folder.id, folder.name); 1123 + } else { 1124 + console.log('Cached folder ID not found, will search...'); 1125 + await Storage.removeItem(STORAGE_KEYS.CO_FOLDER_ID); 1126 + } 1127 + } catch (error) { 1128 + console.log('Failed to get cached folder, will search:', error); 1129 + await Storage.removeItem(STORAGE_KEYS.CO_FOLDER_ID); 1130 + } 1131 + } 1132 + 1133 + // If we don't have a cached folder, search for it 1134 + if (!folder) { 1135 + console.log('Searching for co-app folder...'); 1136 + const folders = await lettaApi.listFolders({ name: 'co-app' }); 1137 + console.log('Folder query result:', folders.length, 'folders'); 1138 + folder = folders.length > 0 ? folders[0] : null; 1139 + console.log('Selected folder:', folder ? { id: folder.id, name: folder.name } : null); 1140 + } 1141 + 1142 + // If still no folder, create it 1143 + if (!folder) { 1144 + console.log('Creating co-app folder...'); 1145 + try { 1146 + folder = await lettaApi.createFolder('co-app', 'Files shared with co'); 1147 + console.log('Folder created:', folder.id, 'name:', folder.name); 1148 + } catch (createError: any) { 1149 + // If 409 conflict, folder was created by another process - try to find it again 1150 + if (createError.status === 409) { 1151 + console.log('Folder already exists (409), retrying fetch...'); 1152 + const foldersRetry = await lettaApi.listFolders({ name: 'co-app' }); 1153 + console.log('Retry folder query result:', foldersRetry.length, 'folders'); 1154 + folder = foldersRetry.length > 0 ? foldersRetry[0] : null; 1155 + if (!folder) { 1156 + console.error('Folder "co-app" not found after 409 conflict'); 1157 + setFilesError('Folder "co-app" exists but could not be retrieved. Try refreshing.'); 1158 + return; 1159 + } 1160 + } else { 1161 + throw createError; 1162 + } 1163 + } 1164 + } 1165 + 1166 + // Cache the folder ID for next time 1167 + await Storage.setItem(STORAGE_KEYS.CO_FOLDER_ID, folder.id); 1168 + console.log('Cached folder ID:', folder.id); 1169 + 1170 + setCoFolder(folder); 1171 + console.log('Co folder ready:', folder.id); 1172 + 1173 + // Attach folder to agent if not already attached 1174 + try { 1175 + await lettaApi.attachFolderToAgent(coAgent.id, folder.id); 1176 + console.log('Folder attached to agent'); 1177 + } catch (error: any) { 1178 + // Might already be attached, ignore error 1179 + console.log('Folder attach info:', error.message); 1180 + } 1181 + 1182 + // Load files 1183 + await loadFolderFiles(folder.id); 1184 + } catch (error: any) { 1185 + console.error('Failed to initialize co folder:', error); 1186 + setFilesError(error.message || 'Failed to initialize folder'); 1187 + } 1188 + }; 1189 + 1190 + const loadFolderFiles = async (folderId?: string) => { 1191 + const id = folderId || coFolder?.id; 1192 + if (!id) return; 1193 + 1194 + setIsLoadingFiles(true); 1195 + setFilesError(null); 1196 + try { 1197 + const files = await lettaApi.listFolderFiles(id); 1198 + setFolderFiles(files); 1199 + } catch (error: any) { 1200 + console.error('Failed to load files:', error); 1201 + setFilesError(error.message || 'Failed to load files'); 1202 + } finally { 1203 + setIsLoadingFiles(false); 1204 + } 1205 + }; 1206 + 1207 + const pickAndUploadFile = async () => { 1208 + if (!coFolder) { 1209 + Alert.alert('Error', 'Folder not initialized'); 1210 + return; 1211 + } 1212 + 1213 + try { 1214 + // Create input element for file selection (web) 1215 + const input = document.createElement('input'); 1216 + input.type = 'file'; 1217 + input.accept = '.pdf,.txt,.md,.json,.csv,.doc,.docx'; 1218 + 1219 + input.onchange = async (e: any) => { 1220 + const file = e.target?.files?.[0]; 1221 + if (!file) return; 1222 + 1223 + console.log('Selected file:', file.name, 'size:', file.size); 1224 + 1225 + // Check file size (10MB limit) 1226 + const MAX_SIZE = 10 * 1024 * 1024; 1227 + if (file.size > MAX_SIZE) { 1228 + Alert.alert('File Too Large', 'Maximum file size is 10MB'); 1229 + return; 1230 + } 1231 + 1232 + setIsUploadingFile(true); 1233 + setUploadProgress(`Uploading ${file.name}...`); 1234 + 1235 + // Show immediate feedback 1236 + console.log(`Starting upload: ${file.name}`); 1237 + 1238 + try { 1239 + // Upload file - this returns the job info 1240 + const result = await lettaApi.uploadFileToFolder(coFolder.id, file); 1241 + console.log('Upload result:', result); 1242 + 1243 + // The upload might complete immediately or return a job 1244 + if (result.id && result.id.startsWith('file-')) { 1245 + // It's a job ID - poll for completion 1246 + setUploadProgress('Processing file...'); 1247 + let attempts = 0; 1248 + const maxAttempts = 30; // 30 seconds max 1249 + 1250 + while (attempts < maxAttempts) { 1251 + await new Promise(resolve => setTimeout(resolve, 1000)); 1252 + 1253 + try { 1254 + const status = await lettaApi.getJobStatus(result.id); 1255 + console.log('Job status:', status.status); 1256 + 1257 + if (status.status === 'completed') { 1258 + console.log('File uploaded successfully'); 1259 + await loadFolderFiles(); 1260 + 1261 + // Close all open files to avoid flooding context 1262 + if (coAgent) { 1263 + try { 1264 + await lettaApi.closeAllFiles(coAgent.id); 1265 + console.log('Closed all open files after upload'); 1266 + } catch (err) { 1267 + console.error('Failed to close files:', err); 1268 + } 1269 + } 1270 + 1271 + setUploadProgress(''); 1272 + Alert.alert('Success', `${file.name} uploaded successfully`); 1273 + break; 1274 + } else if (status.status === 'failed') { 1275 + throw new Error('Upload failed: ' + (status.metadata || 'Unknown error')); 1276 + } 1277 + } catch (jobError: any) { 1278 + // If job not found (404), it might have completed already 1279 + if (jobError.status === 404) { 1280 + console.log('Job not found - assuming completed'); 1281 + await loadFolderFiles(); 1282 + 1283 + // Close all open files to avoid flooding context 1284 + if (coAgent) { 1285 + try { 1286 + await lettaApi.closeAllFiles(coAgent.id); 1287 + console.log('Closed all open files after upload'); 1288 + } catch (err) { 1289 + console.error('Failed to close files:', err); 1290 + } 1291 + } 1292 + 1293 + setUploadProgress(''); 1294 + Alert.alert('Success', `${file.name} uploaded successfully`); 1295 + break; 1296 + } 1297 + throw jobError; 1298 + } 1299 + 1300 + attempts++; 1301 + } 1302 + 1303 + if (attempts >= maxAttempts) { 1304 + throw new Error('Upload processing timed out'); 1305 + } 1306 + } else { 1307 + // Upload completed immediately 1308 + console.log('File uploaded immediately'); 1309 + await loadFolderFiles(); 1310 + 1311 + // Close all open files to avoid flooding context 1312 + if (coAgent) { 1313 + try { 1314 + await lettaApi.closeAllFiles(coAgent.id); 1315 + console.log('Closed all open files after upload'); 1316 + } catch (err) { 1317 + console.error('Failed to close files:', err); 1318 + } 1319 + } 1320 + 1321 + setUploadProgress(''); 1322 + Alert.alert('Success', `${file.name} uploaded successfully`); 1323 + } 1324 + } catch (error: any) { 1325 + console.error('Upload error:', error); 1326 + setUploadProgress(''); 1327 + Alert.alert('Upload Failed', error.message || 'Failed to upload file'); 1328 + } finally { 1329 + setIsUploadingFile(false); 1330 + } 1331 + }; 1332 + 1333 + input.click(); 1334 + } catch (error: any) { 1335 + console.error('File picker error:', error); 1336 + Alert.alert('Error', 'Failed to open file picker'); 1337 + } 1338 + }; 1339 + 1340 + const deleteFile = async (fileId: string, fileName: string) => { 1341 + if (!coFolder) return; 1342 + 1343 + const confirmed = Platform.OS === 'web' 1344 + ? window.confirm(`Are you sure you want to delete "${fileName}"?`) 1345 + : await new Promise<boolean>((resolve) => { 1346 + Alert.alert( 1347 + 'Delete File', 1348 + `Are you sure you want to delete "${fileName}"?`, 1349 + [ 1350 + { text: 'Cancel', style: 'cancel', onPress: () => resolve(false) }, 1351 + { text: 'Delete', style: 'destructive', onPress: () => resolve(true) }, 1352 + ] 1353 + ); 1354 + }); 1355 + 1356 + if (!confirmed) return; 1357 + 1358 + try { 1359 + await lettaApi.deleteFile(coFolder.id, fileId); 1360 + await loadFolderFiles(); 1361 + if (Platform.OS === 'web') { 1362 + window.alert('File deleted successfully'); 1363 + } else { 1364 + Alert.alert('Success', 'File deleted'); 1365 + } 1366 + } catch (error: any) { 1367 + console.error('Delete error:', error); 1368 + if (Platform.OS === 'web') { 1369 + window.alert('Failed to delete file: ' + (error.message || 'Unknown error')); 1370 + } else { 1371 + Alert.alert('Error', 'Failed to delete file: ' + (error.message || 'Unknown error')); 1372 + } 1373 + } 1374 + }; 1375 + 1376 + useEffect(() => { 1377 + if (coAgent && currentView === 'knowledge') { 1378 + if (knowledgeTab === 'core') { 1379 + loadMemoryBlocks(); 1380 + } else if (knowledgeTab === 'archival') { 1381 + loadPassages(true); 1382 + } 1383 + } 1384 + }, [coAgent, currentView, knowledgeTab]); 1385 + 1386 + useEffect(() => { 1387 + if (coAgent && sidebarVisible) { 1388 + if (!coFolder) { 1389 + initializeCoFolder(); 1390 + } else { 1391 + loadFolderFiles(); 1392 + } 1393 + } 1394 + }, [coAgent, sidebarVisible]); 1395 + 1396 + // Initialize folder when agent is ready 1397 + useEffect(() => { 1398 + if (coAgent && !coFolder) { 1399 + initializeCoFolder(); 1400 + } 1401 + }, [coAgent]); 1402 + 1403 + // State for tracking expanded reasoning 1404 + const [expandedReasoning, setExpandedReasoning] = useState<Set<string>>(new Set()); 1405 + const [expandedCompaction, setExpandedCompaction] = useState<Set<string>>(new Set()); 1406 + const [expandedToolReturns, setExpandedToolReturns] = useState<Set<string>>(new Set()); 1407 + 1408 + // Auto-expand reasoning blocks by default 1409 + useEffect(() => { 1410 + const reasoningMessageIds = messages 1411 + .filter(msg => msg.reasoning && msg.reasoning.trim().length > 0) 1412 + .map(msg => msg.id); 1413 + 1414 + if (reasoningMessageIds.length > 0) { 1415 + setExpandedReasoning(prev => { 1416 + const next = new Set(prev); 1417 + reasoningMessageIds.forEach(id => next.add(id)); 1418 + return next; 1419 + }); 1420 + } 1421 + }, [messages]); 1422 + 1423 + // Animate sidebar 1424 + useEffect(() => { 1425 + Animated.timing(sidebarAnimRef, { 1426 + toValue: sidebarVisible ? 1 : 0, 1427 + duration: 250, 1428 + useNativeDriver: false, 1429 + }).start(); 1430 + }, [sidebarVisible]); 1431 + 1432 + const toggleReasoning = useCallback((messageId: string) => { 1433 + setExpandedReasoning(prev => { 1434 + const next = new Set(prev); 1435 + if (next.has(messageId)) { 1436 + next.delete(messageId); 1437 + } else { 1438 + next.add(messageId); 1439 + } 1440 + return next; 1441 + }); 1442 + }, []); 1443 + 1444 + const toggleCompaction = useCallback((messageId: string) => { 1445 + setExpandedCompaction(prev => { 1446 + const next = new Set(prev); 1447 + if (next.has(messageId)) { 1448 + next.delete(messageId); 1449 + } else { 1450 + next.add(messageId); 1451 + } 1452 + return next; 1453 + }); 1454 + }, []); 1455 + 1456 + const toggleToolReturn = useCallback((messageId: string) => { 1457 + setExpandedToolReturns(prev => { 1458 + const next = new Set(prev); 1459 + if (next.has(messageId)) { 1460 + next.delete(messageId); 1461 + } else { 1462 + next.add(messageId); 1463 + } 1464 + return next; 1465 + }); 1466 + }, []); 1467 + 1468 + // Group messages for efficient FlatList rendering 1469 + type MessageGroup = 1470 + | { key: string; type: 'toolPair'; call: LettaMessage; ret?: LettaMessage; reasoning?: string } 1471 + | { key: string; type: 'message'; message: LettaMessage; reasoning?: string }; 1472 + 1473 + // Helper to check if a tool call has a result 1474 + const toolCallHasResult = useMemo(() => { 1475 + const hasResultMap = new Map<string, boolean>(); 1476 + for (let i = 0; i < messages.length; i++) { 1477 + const msg = messages[i]; 1478 + if (msg.message_type === 'tool_call_message') { 1479 + // Check if the next message is a tool_return 1480 + const nextMsg = messages[i + 1]; 1481 + hasResultMap.set(msg.id, nextMsg?.message_type === 'tool_return_message'); 1482 + } 1483 + } 1484 + return hasResultMap; 1485 + }, [messages]); 1486 + 1487 + const displayMessages = useMemo(() => { 1488 + // Sort messages by created_at timestamp to ensure correct chronological order 1489 + const sortedMessages = [...messages].sort((a, b) => { 1490 + const timeA = new Date(a.created_at || 0).getTime(); 1491 + const timeB = new Date(b.created_at || 0).getTime(); 1492 + return timeA - timeB; 1493 + }); 1494 + 1495 + // Filter out system messages and login/heartbeat messages 1496 + const filtered = sortedMessages.filter(msg => { 1497 + if (msg.message_type === 'system_message') return false; 1498 + 1499 + if (msg.message_type === 'user_message' && msg.content) { 1500 + try { 1501 + const contentStr = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content); 1502 + const parsed = JSON.parse(contentStr); 1503 + if (parsed?.type === 'login' || parsed?.type === 'heartbeat') { 1504 + return false; 1505 + } 1506 + } catch { 1507 + // Not JSON, keep the message 1508 + } 1509 + } 1510 + 1511 + return true; 1512 + }); 1513 + 1514 + // Limit to most recent messages to avoid rendering issues with large history 1515 + // Keep enough for smooth scrolling but not so many that initial render is slow 1516 + const MAX_DISPLAY_MESSAGES = 100; 1517 + const limited = filtered.length > MAX_DISPLAY_MESSAGES 1518 + ? filtered.slice(-MAX_DISPLAY_MESSAGES) // Take LAST N messages (most recent) 1519 + : filtered; 1520 + 1521 + console.log('[DISPLAY] Total messages:', limited.length, filtered.length > MAX_DISPLAY_MESSAGES ? `(limited from ${filtered.length})` : ''); 1522 + limited.forEach((msg, idx) => { 1523 + console.log(`[DISPLAY ${idx}] ${msg.message_type} - ${msg.id.substring(0, 8)} - reasoning: ${!!msg.reasoning}`); 1524 + }); 1525 + 1526 + return limited; 1527 + }, [messages]); 1528 + 1529 + // Animate rainbow gradient for "co is thinking", input box, reasoning sections, and empty state 1530 + useEffect(() => { 1531 + if (isStreaming || isInputFocused || expandedReasoning.size > 0 || displayMessages.length === 0) { 1532 + rainbowAnimValue.setValue(0); 1533 + Animated.loop( 1534 + Animated.timing(rainbowAnimValue, { 1535 + toValue: 1, 1536 + duration: 3000, 1537 + useNativeDriver: false, 1538 + }) 1539 + ).start(); 1540 + } else { 1541 + rainbowAnimValue.stopAnimation(); 1542 + } 1543 + }, [isStreaming, isInputFocused, expandedReasoning, displayMessages.length]); 1544 + 1545 + const renderMessage = useCallback(({ item }: { item: LettaMessage }) => { 1546 + const msg = item; 1547 + const isUser = msg.message_type === 'user_message'; 1548 + const isSystem = msg.message_type === 'system_message'; 1549 + const isToolCall = msg.message_type === 'tool_call_message'; 1550 + const isToolReturn = msg.message_type === 'tool_return_message'; 1551 + const isAssistant = msg.message_type === 'assistant_message'; 1552 + const isReasoning = msg.message_type === 'reasoning_message'; 1553 + 1554 + if (isSystem) return null; 1555 + 1556 + // Handle reasoning messages 1557 + if (isReasoning) { 1558 + const isReasoningExpanded = expandedReasoning.has(msg.id); 1559 + 1560 + return ( 1561 + <View style={styles.messageContainer}> 1562 + <ReasoningToggle 1563 + reasoning={msg.reasoning || ''} 1564 + messageId={msg.id} 1565 + isExpanded={isReasoningExpanded} 1566 + onToggle={() => toggleReasoning(msg.id)} 1567 + isDark={colorScheme === 'dark'} 1568 + /> 1569 + </View> 1570 + ); 1571 + } 1572 + 1573 + // Handle tool calls - find and render with their result 1574 + if (isToolCall) { 1575 + // Find the corresponding tool return (next message in the list) 1576 + const msgIndex = displayMessages.findIndex(m => m.id === msg.id); 1577 + const nextMsg = msgIndex >= 0 && msgIndex < displayMessages.length - 1 ? displayMessages[msgIndex + 1] : null; 1578 + const toolReturn = nextMsg && nextMsg.message_type === 'tool_return_message' ? nextMsg : null; 1579 + 1580 + return ( 1581 + <View style={styles.messageContainer}> 1582 + <ToolCallItem 1583 + callText={msg.content} 1584 + resultText={toolReturn?.content} 1585 + reasoning={msg.reasoning} 1586 + hasResult={!!toolReturn} 1587 + isDark={colorScheme === 'dark'} 1588 + /> 1589 + </View> 1590 + ); 1591 + } 1592 + 1593 + // Skip tool returns - they're rendered with their tool call 1594 + if (isToolReturn) { 1595 + // Check if previous message is a tool call 1596 + const msgIndex = displayMessages.findIndex(m => m.id === msg.id); 1597 + const prevMsg = msgIndex > 0 ? displayMessages[msgIndex - 1] : null; 1598 + if (prevMsg && prevMsg.message_type === 'tool_call_message') { 1599 + return null; // Already rendered with the tool call 1600 + } 1601 + 1602 + // Orphaned tool return (no matching tool call) - render it standalone 1603 + const isExpanded = expandedToolReturns.has(msg.id); 1604 + return ( 1605 + <View style={styles.messageContainer}> 1606 + <View style={styles.toolReturnContainer}> 1607 + <TouchableOpacity 1608 + style={styles.toolReturnHeader} 1609 + onPress={() => toggleToolReturn(msg.id)} 1610 + activeOpacity={0.7} 1611 + > 1612 + <Ionicons 1613 + name={isExpanded ? 'chevron-down' : 'chevron-forward'} 1614 + size={12} 1615 + color={darkTheme.colors.text.tertiary} 1616 + /> 1617 + <Text style={styles.toolReturnLabel}>Result (orphaned)</Text> 1618 + </TouchableOpacity> 1619 + {isExpanded && ( 1620 + <View style={styles.toolReturnContent}> 1621 + <MessageContent content={msg.content} isUser={false} isDark={colorScheme === 'dark'} /> 1622 + </View> 1623 + )} 1624 + </View> 1625 + </View> 1626 + ); 1627 + } 1628 + 1629 + if (isUser) { 1630 + // Check if this is a system_alert compaction message 1631 + let isCompactionAlert = false; 1632 + let compactionMessage = ''; 1633 + try { 1634 + const parsed = JSON.parse(msg.content); 1635 + if (parsed?.type === 'system_alert') { 1636 + isCompactionAlert = true; 1637 + // Extract the message field from the embedded JSON in the message text 1638 + const messageText = parsed.message || ''; 1639 + // Try to extract JSON from the message (it's usually in a code block) 1640 + const jsonMatch = messageText.match(/```json\s*(\{[\s\S]*?\})\s*```/); 1641 + if (jsonMatch) { 1642 + try { 1643 + const innerJson = JSON.parse(jsonMatch[1]); 1644 + compactionMessage = innerJson.message || messageText; 1645 + } catch { 1646 + compactionMessage = messageText; 1647 + } 1648 + } else { 1649 + compactionMessage = messageText; 1650 + } 1651 + 1652 + // Strip out the "Note: prior messages..." preamble 1653 + compactionMessage = compactionMessage.replace(/^Note: prior messages have been hidden from view.*?The following is a summary of the previous messages:\s*/is, ''); 1654 + } 1655 + } catch { 1656 + // Not JSON, treat as normal user message 1657 + } 1658 + 1659 + if (isCompactionAlert) { 1660 + // Hide compaction if user has disabled it in settings 1661 + if (!showCompaction) { 1662 + return null; 1663 + } 1664 + 1665 + // Render compaction alert as thin grey expandable line 1666 + const isCompactionExpanded = expandedCompaction.has(msg.id); 1667 + 1668 + return ( 1669 + <View key={item.key} style={styles.compactionContainer}> 1670 + <TouchableOpacity 1671 + onPress={() => toggleCompaction(msg.id)} 1672 + style={styles.compactionLine} 1673 + activeOpacity={0.7} 1674 + > 1675 + <View style={styles.compactionDivider} /> 1676 + <Text style={styles.compactionLabel}>compaction</Text> 1677 + <View style={styles.compactionDivider} /> 1678 + <Ionicons 1679 + name={isCompactionExpanded ? 'chevron-up' : 'chevron-down'} 1680 + size={12} 1681 + color={(colorScheme === 'dark' ? darkTheme : lightTheme).colors.text.tertiary} 1682 + style={styles.compactionChevron} 1683 + /> 1684 + </TouchableOpacity> 1685 + {isCompactionExpanded && ( 1686 + <View style={styles.compactionMessageContainer}> 1687 + <MessageContent content={compactionMessage} /> 1688 + </View> 1689 + )} 1690 + </View> 1691 + ); 1692 + } 1693 + 1694 + // Parse message content to check for multipart (images) 1695 + let textContent: string = ''; 1696 + let imageContent: Array<{type: string, source: {type: string, data: string, mediaType: string}}> = []; 1697 + 1698 + if (typeof msg.content === 'object' && Array.isArray(msg.content)) { 1699 + // Multipart message with images 1700 + imageContent = msg.content.filter((item: any) => item.type === 'image'); 1701 + const textParts = msg.content.filter((item: any) => item.type === 'text'); 1702 + textContent = textParts.map((item: any) => item.text || '').filter(t => t).join('\n'); 1703 + } else if (typeof msg.content === 'string') { 1704 + textContent = msg.content; 1705 + } else { 1706 + // Fallback: convert to string 1707 + textContent = String(msg.content || ''); 1708 + } 1709 + 1710 + // Skip rendering if no content at all 1711 + if (!textContent.trim() && imageContent.length === 0) { 1712 + return null; 1713 + } 1714 + 1715 + return ( 1716 + <View 1717 + key={item.key} 1718 + style={[styles.messageContainer, styles.userMessageContainer]} 1719 + > 1720 + <View 1721 + style={[ 1722 + styles.messageBubble, 1723 + styles.userBubble, 1724 + { backgroundColor: colorScheme === 'dark' ? CoColors.pureWhite : CoColors.deepBlack } 1725 + ]} 1726 + // @ts-ignore - web-only data attribute for CSS targeting 1727 + dataSet={{ userMessage: 'true' }} 1728 + > 1729 + {/* Display images */} 1730 + {imageContent.length > 0 && ( 1731 + <View style={styles.messageImagesContainer}> 1732 + {imageContent.map((img: any, idx: number) => { 1733 + const uri = img.source.type === 'url' 1734 + ? img.source.url 1735 + : `data:${img.source.media_type || img.source.mediaType};base64,${img.source.data}`; 1736 + 1737 + return ( 1738 + <Image 1739 + key={idx} 1740 + source={{ uri }} 1741 + style={styles.messageImage} 1742 + /> 1743 + ); 1744 + })} 1745 + </View> 1746 + )} 1747 + 1748 + {/* Display text content */} 1749 + {textContent.trim().length > 0 && ( 1750 + <ExpandableMessageContent 1751 + content={textContent} 1752 + isUser={isUser} 1753 + isDark={colorScheme === 'dark'} 1754 + lineLimit={3} 1755 + /> 1756 + )} 1757 + </View> 1758 + </View> 1759 + ); 1760 + } else { 1761 + const isReasoningExpanded = expandedReasoning.has(msg.id); 1762 + const isLastMessage = displayMessages[displayMessages.length - 1]?.id === msg.id; 1763 + const shouldHaveMinHeight = isLastMessage && lastMessageNeedsSpace; 1764 + 1765 + return ( 1766 + <View style={[ 1767 + styles.assistantFullWidthContainer, 1768 + shouldHaveMinHeight && { minHeight: Math.max(containerHeight * 0.9, 450) } 1769 + ]}> 1770 + {msg.reasoning && ( 1771 + <ReasoningToggle 1772 + reasoning={msg.reasoning} 1773 + messageId={msg.id} 1774 + isExpanded={isReasoningExpanded} 1775 + onToggle={() => toggleReasoning(msg.id)} 1776 + isDark={colorScheme === 'dark'} 1777 + /> 1778 + )} 1779 + <Text style={[styles.assistantLabel, { color: theme.colors.text.primary }]}>(co said)</Text> 1780 + <View style={{ position: 'relative' }}> 1781 + <ExpandableMessageContent 1782 + content={msg.content} 1783 + isUser={isUser} 1784 + isDark={colorScheme === 'dark'} 1785 + lineLimit={20} 1786 + /> 1787 + <View style={styles.copyButtonContainer}> 1788 + <TouchableOpacity 1789 + onPress={() => copyToClipboard(msg.content, msg.id)} 1790 + style={styles.copyButton} 1791 + activeOpacity={0.7} 1792 + testID="copy-button" 1793 + > 1794 + <Ionicons 1795 + name={copiedMessageId === msg.id ? "checkmark-outline" : "copy-outline"} 1796 + size={16} 1797 + color={copiedMessageId === msg.id ? (colorScheme === 'dark' ? darkTheme : lightTheme).colors.interactive.primary : (colorScheme === 'dark' ? darkTheme : lightTheme).colors.text.tertiary} 1798 + /> 1799 + </TouchableOpacity> 1800 + </View> 1801 + </View> 1802 + </View> 1803 + ); 1804 + } 1805 + 1806 + return null; 1807 + }, [expandedCompaction, expandedReasoning, expandedToolReturns, displayMessages, lastMessageNeedsSpace, containerHeight, colorScheme, copiedMessageId, toggleCompaction, toggleReasoning, toggleToolReturn, copyToClipboard, toolCallHasResult]); 1808 + 1809 + const keyExtractor = useCallback((item: LettaMessage) => `${item.id}-${item.message_type}`, []); 1810 + 1811 + const handleScroll = useCallback((e: any) => { 1812 + const y = e.nativeEvent.contentOffset.y; 1813 + setScrollY(y); 1814 + const threshold = 80; 1815 + const distanceFromBottom = Math.max(0, contentHeight - (y + containerHeight)); 1816 + setShowScrollToBottom(distanceFromBottom > threshold); 1817 + }, [contentHeight, containerHeight]); 1818 + 1819 + const handleContentSizeChange = useCallback((_w: number, h: number) => { 1820 + setContentHeight(h); 1821 + if (pendingJumpToBottomRef.current && containerHeight > 0 && pendingJumpRetriesRef.current > 0) { 1822 + const offset = Math.max(0, h - containerHeight); 1823 + scrollViewRef.current?.scrollToOffset({ offset, animated: false }); 1824 + setShowScrollToBottom(false); 1825 + pendingJumpRetriesRef.current -= 1; 1826 + if (pendingJumpRetriesRef.current <= 0) pendingJumpToBottomRef.current = false; 1827 + } 1828 + }, [containerHeight]); 1829 + 1830 + const handleMessagesLayout = (e: any) => { 1831 + const h = e.nativeEvent.layout.height; 1832 + setContainerHeight(h); 1833 + if (pendingJumpToBottomRef.current && contentHeight > 0 && pendingJumpRetriesRef.current > 0) { 1834 + const offset = Math.max(0, contentHeight - h); 1835 + scrollViewRef.current?.scrollToOffset({ offset, animated: false }); 1836 + setShowScrollToBottom(false); 1837 + pendingJumpRetriesRef.current -= 1; 1838 + if (pendingJumpRetriesRef.current <= 0) pendingJumpToBottomRef.current = false; 1839 + } 1840 + }; 1841 + 1842 + const scrollToBottom = () => { 1843 + scrollViewRef.current?.scrollToEnd({ animated: true }); 1844 + setShowScrollToBottom(false); 1845 + }; 1846 + 1847 + const handleInputLayout = useCallback((e: any) => { 1848 + setInputContainerHeight(e.nativeEvent.layout.height || 0); 1849 + }, []); 1850 + 1851 + const handleInputFocusChange = useCallback((focused: boolean) => { 1852 + setIsInputFocused(focused); 1853 + }, []); 1854 + 1855 + const inputWrapperStyle = useMemo(() => ({ 1856 + borderRadius: 24, 1857 + borderWidth: 2, 1858 + borderColor: colorScheme === 'dark' ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)', 1859 + shadowColor: '#000', 1860 + shadowOffset: { width: 0, height: 2 }, 1861 + shadowOpacity: 0.1, 1862 + shadowRadius: 8, 1863 + elevation: 2, 1864 + }), [colorScheme]); 1865 + 1866 + const sendButtonStyle = useMemo(() => ({ 1867 + backgroundColor: (!hasInputText && selectedImages.length === 0) || isSendingMessage 1868 + ? 'transparent' 1869 + : colorScheme === 'dark' ? CoColors.pureWhite : CoColors.deepBlack 1870 + }), [hasInputText, selectedImages.length, isSendingMessage, colorScheme]); 1871 + 1872 + const sendIconColor = useMemo(() => 1873 + (!hasInputText && selectedImages.length === 0) 1874 + ? '#444444' 1875 + : colorScheme === 'dark' ? CoColors.deepBlack : CoColors.pureWhite 1876 + , [hasInputText, selectedImages.length, colorScheme]); 1877 + 1878 + if (isLoadingToken || !fontsLoaded) { 1879 + return ( 1880 + <SafeAreaView style={[styles.loadingContainer, { backgroundColor: theme.colors.background.primary }]}> 1881 + <ActivityIndicator size="large" color={theme.colors.interactive.primary} /> 1882 + <StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} /> 1883 + </SafeAreaView> 1884 + ); 57 1885 } 58 1886 59 - // Show login screen if not connected 60 1887 if (!isConnected) { 61 1888 return ( 62 1889 <CoLoginScreen 63 - onLogin={connectWithToken} 64 - isConnecting={isConnecting} 65 - error={connectionError || undefined} 1890 + onLogin={handleLogin} 1891 + isLoading={isConnecting} 1892 + error={connectionError} 66 1893 /> 67 1894 ); 68 1895 } 69 1896 70 - // Show loading while Co agent initializes 1897 + if (isRefreshingCo) { 1898 + return ( 1899 + <SafeAreaView style={[styles.loadingContainer, { backgroundColor: theme.colors.background.primary }]}> 1900 + <ActivityIndicator size="large" color={theme.colors.interactive.primary} /> 1901 + <Text style={[styles.loadingText, { color: theme.colors.text.secondary }]}>Refreshing co...</Text> 1902 + <StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} /> 1903 + </SafeAreaView> 1904 + ); 1905 + } 1906 + 71 1907 if (isInitializingCo || !coAgent) { 72 1908 return ( 73 - <View style={[styles.loadingContainer, { backgroundColor: theme.colors.background.primary }]}> 74 - <LogoLoader /> 75 - </View> 1909 + <SafeAreaView style={[styles.loadingContainer, { backgroundColor: theme.colors.background.primary }]}> 1910 + <ActivityIndicator size="large" color={theme.colors.interactive.primary} /> 1911 + <Text style={[styles.loadingText, { color: theme.colors.text.secondary }]}>Initializing co...</Text> 1912 + <StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} /> 1913 + </SafeAreaView> 76 1914 ); 77 1915 } 78 1916 79 - // Main app 1917 + // Main chat view 80 1918 return ( 81 - <> 1919 + <View 1920 + style={[styles.container, { backgroundColor: theme.colors.background.primary }]} 1921 + // @ts-ignore - web-only data attribute 1922 + dataSet={{ theme: colorScheme }} 1923 + > 1924 + {/* Sidebar */} 1925 + <Animated.View 1926 + style={[ 1927 + styles.sidebarContainer, 1928 + { 1929 + paddingTop: insets.top, 1930 + backgroundColor: theme.colors.background.secondary, 1931 + borderRightColor: theme.colors.border.primary, 1932 + width: sidebarAnimRef.interpolate({ 1933 + inputRange: [0, 1], 1934 + outputRange: [0, 280], 1935 + }), 1936 + }, 1937 + ]} 1938 + > 1939 + <View style={styles.sidebarHeader}> 1940 + <Text style={[styles.sidebarTitle, { color: theme.colors.text.primary }]}>Menu</Text> 1941 + <TouchableOpacity onPress={() => setSidebarVisible(false)} style={styles.closeSidebar}> 1942 + <Ionicons name="close" size={24} color={theme.colors.text.primary} /> 1943 + </TouchableOpacity> 1944 + </View> 1945 + 1946 + <FlatList 1947 + style={{ flex: 1 }} 1948 + contentContainerStyle={{ flexGrow: 1 }} 1949 + ListHeaderComponent={ 1950 + <View style={styles.menuItems}> 1951 + <TouchableOpacity 1952 + style={[styles.menuItem, { borderBottomColor: theme.colors.border.primary }]} 1953 + onPress={() => { 1954 + setCurrentView('knowledge'); 1955 + loadMemoryBlocks(); 1956 + }} 1957 + > 1958 + <Ionicons name="library-outline" size={24} color={theme.colors.text.primary} /> 1959 + <Text style={[styles.menuItemText, { color: theme.colors.text.primary }]}>Memory</Text> 1960 + </TouchableOpacity> 1961 + 1962 + <TouchableOpacity 1963 + style={[styles.menuItem, { borderBottomColor: theme.colors.border.primary }]} 1964 + onPress={() => { 1965 + setCurrentView('settings'); 1966 + }} 1967 + > 1968 + <Ionicons name="settings-outline" size={24} color={theme.colors.text.primary} /> 1969 + <Text style={[styles.menuItemText, { color: theme.colors.text.primary }]}>Settings</Text> 1970 + </TouchableOpacity> 1971 + 1972 + <TouchableOpacity 1973 + style={[styles.menuItem, { borderBottomColor: theme.colors.border.primary }]} 1974 + onPress={() => { 1975 + toggleColorScheme(); 1976 + }} 1977 + > 1978 + <Ionicons 1979 + name={colorScheme === 'dark' ? 'sunny-outline' : 'moon-outline'} 1980 + size={24} 1981 + color={theme.colors.text.primary} 1982 + /> 1983 + <Text style={[styles.menuItemText, { color: theme.colors.text.primary }]}> 1984 + {colorScheme === 'dark' ? 'Light Mode' : 'Dark Mode'} 1985 + </Text> 1986 + </TouchableOpacity> 1987 + 1988 + <TouchableOpacity 1989 + style={[styles.menuItem, { borderBottomColor: theme.colors.border.primary }]} 1990 + onPress={() => { 1991 + if (coAgent) { 1992 + Linking.openURL(`https://app.letta.com/agents/${coAgent.id}`); 1993 + } 1994 + }} 1995 + disabled={!coAgent} 1996 + > 1997 + <Ionicons name="open-outline" size={24} color={theme.colors.text.primary} /> 1998 + <Text style={[styles.menuItemText, { color: theme.colors.text.primary }]}>Open in Browser</Text> 1999 + </TouchableOpacity> 2000 + 2001 + {developerMode && ( 2002 + <TouchableOpacity 2003 + style={[styles.menuItem, { borderBottomColor: theme.colors.border.primary }]} 2004 + onPress={async () => { 2005 + console.log('Refresh Co button pressed'); 2006 + const confirmed = Platform.OS === 'web' 2007 + ? window.confirm('This will delete the current co agent and create a new one. All conversation history will be lost. Are you sure?') 2008 + : await new Promise<boolean>((resolve) => { 2009 + Alert.alert( 2010 + 'Refresh Co Agent', 2011 + 'This will delete the current co agent and create a new one. All conversation history will be lost. Are you sure?', 2012 + [ 2013 + { text: 'Cancel', style: 'cancel', onPress: () => resolve(false) }, 2014 + { text: 'Refresh', style: 'destructive', onPress: () => resolve(true) }, 2015 + ] 2016 + ); 2017 + }); 2018 + 2019 + if (!confirmed) return; 2020 + 2021 + console.log('Refresh confirmed, starting process...'); 2022 + setSidebarVisible(false); 2023 + setIsRefreshingCo(true); 2024 + try { 2025 + if (coAgent) { 2026 + console.log('Deleting agent:', coAgent.id); 2027 + const deleteResult = await lettaApi.deleteAgent(coAgent.id); 2028 + console.log('Delete result:', deleteResult); 2029 + console.log('Agent deleted successfully, clearing state...'); 2030 + setCoAgent(null); 2031 + setMessages([]); 2032 + setEarliestCursor(null); 2033 + setHasMoreBefore(false); 2034 + console.log('Initializing new co agent...'); 2035 + await initializeCo(); 2036 + console.log('Co agent refreshed successfully'); 2037 + setIsRefreshingCo(false); 2038 + if (Platform.OS === 'web') { 2039 + window.alert('Co agent refreshed successfully'); 2040 + } else { 2041 + Alert.alert('Success', 'Co agent refreshed successfully'); 2042 + } 2043 + } 2044 + } catch (error: any) { 2045 + console.error('=== ERROR REFRESHING CO ==='); 2046 + console.error('Error type:', typeof error); 2047 + console.error('Error message:', error?.message); 2048 + console.error('Error stack:', error?.stack); 2049 + console.error('Full error:', error); 2050 + setIsRefreshingCo(false); 2051 + if (Platform.OS === 'web') { 2052 + window.alert('Failed to refresh co: ' + (error.message || 'Unknown error')); 2053 + } else { 2054 + Alert.alert('Error', 'Failed to refresh co: ' + (error.message || 'Unknown error')); 2055 + } 2056 + } 2057 + }} 2058 + > 2059 + <Ionicons name="refresh-outline" size={24} color={theme.colors.status.error} /> 2060 + <Text style={[styles.menuItemText, { color: theme.colors.status.error }]}>Refresh Co</Text> 2061 + </TouchableOpacity> 2062 + )} 2063 + 2064 + <TouchableOpacity 2065 + style={[styles.menuItem, { borderBottomColor: theme.colors.border.primary }]} 2066 + onPress={() => { 2067 + setSidebarVisible(false); 2068 + handleLogout(); 2069 + }} 2070 + > 2071 + <Ionicons name="log-out-outline" size={24} color={theme.colors.text.primary} /> 2072 + <Text style={[styles.menuItemText, { color: theme.colors.text.primary }]}>Logout</Text> 2073 + </TouchableOpacity> 2074 + </View> 2075 + } 2076 + data={[]} 2077 + renderItem={() => null} 2078 + /> 2079 + </Animated.View> 2080 + 2081 + {/* Main content area */} 2082 + <View style={styles.mainContent}> 2083 + {/* Header */} 2084 + <View style={[ 2085 + styles.header, 2086 + { paddingTop: insets.top, backgroundColor: theme.colors.background.secondary, borderBottomColor: theme.colors.border.primary }, 2087 + displayMessages.length === 0 && { backgroundColor: 'transparent', borderBottomWidth: 0 } 2088 + ]}> 2089 + <TouchableOpacity onPress={() => setSidebarVisible(!sidebarVisible)} style={styles.menuButton}> 2090 + <Ionicons name="menu" size={24} color={colorScheme === 'dark' ? '#FFFFFF' : theme.colors.text.primary} /> 2091 + </TouchableOpacity> 2092 + 2093 + {displayMessages.length > 0 && ( 2094 + <> 2095 + <View style={styles.headerCenter}> 2096 + <TouchableOpacity 2097 + onPress={() => { 2098 + setHeaderClickCount(prev => prev + 1); 2099 + 2100 + if (headerClickTimeoutRef.current) { 2101 + clearTimeout(headerClickTimeoutRef.current); 2102 + } 2103 + 2104 + headerClickTimeoutRef.current = setTimeout(() => { 2105 + if (headerClickCount >= 6) { 2106 + setDeveloperMode(!developerMode); 2107 + if (Platform.OS === 'web') { 2108 + window.alert(developerMode ? 'Developer mode disabled' : 'Developer mode enabled'); 2109 + } else { 2110 + Alert.alert('Developer Mode', developerMode ? 'Disabled' : 'Enabled'); 2111 + } 2112 + } 2113 + setHeaderClickCount(0); 2114 + }, 2000); 2115 + }} 2116 + > 2117 + <Text style={[styles.headerTitle, { color: theme.colors.text.primary }]}>co</Text> 2118 + </TouchableOpacity> 2119 + </View> 2120 + 2121 + <View style={styles.headerSpacer} /> 2122 + </> 2123 + )} 2124 + </View> 2125 + 2126 + {/* View Switcher - hidden when chat is empty */} 2127 + {displayMessages.length > 0 && ( 2128 + <View style={[styles.viewSwitcher, { backgroundColor: theme.colors.background.secondary }]}> 2129 + <TouchableOpacity 2130 + style={[ 2131 + styles.viewSwitcherButton, 2132 + currentView === 'you' && { backgroundColor: theme.colors.background.tertiary } 2133 + ]} 2134 + onPress={() => { 2135 + setCurrentView('you'); 2136 + loadMemoryBlocks(); 2137 + }} 2138 + > 2139 + <Text style={[ 2140 + styles.viewSwitcherText, 2141 + { color: currentView === 'you' ? theme.colors.text.primary : theme.colors.text.tertiary } 2142 + ]}>You</Text> 2143 + </TouchableOpacity> 2144 + <TouchableOpacity 2145 + style={[ 2146 + styles.viewSwitcherButton, 2147 + currentView === 'chat' && { backgroundColor: theme.colors.background.tertiary } 2148 + ]} 2149 + onPress={() => setCurrentView('chat')} 2150 + > 2151 + <Text style={[ 2152 + styles.viewSwitcherText, 2153 + { color: currentView === 'chat' ? theme.colors.text.primary : theme.colors.text.tertiary } 2154 + ]}>Chat</Text> 2155 + </TouchableOpacity> 2156 + <TouchableOpacity 2157 + style={[ 2158 + styles.viewSwitcherButton, 2159 + currentView === 'knowledge' && { backgroundColor: theme.colors.background.tertiary } 2160 + ]} 2161 + onPress={() => { 2162 + setCurrentView('knowledge'); 2163 + loadMemoryBlocks(); 2164 + }} 2165 + > 2166 + <Text style={[ 2167 + styles.viewSwitcherText, 2168 + { color: currentView === 'knowledge' ? theme.colors.text.primary : theme.colors.text.tertiary } 2169 + ]}>Memory</Text> 2170 + </TouchableOpacity> 2171 + </View> 2172 + )} 2173 + 2174 + {/* View Content */} 2175 + <KeyboardAvoidingView 2176 + behavior={Platform.OS === 'ios' ? 'padding' : undefined} 2177 + style={styles.chatRow} 2178 + keyboardVerticalOffset={Platform.OS === 'ios' ? insets.top + 60 : 0} 2179 + enabled={Platform.OS === 'ios'} 2180 + > 2181 + {/* You View */} 2182 + <View style={[styles.memoryViewContainer, { display: currentView === 'you' ? 'flex' : 'none', backgroundColor: theme.colors.background.primary }]}> 2183 + {!hasCheckedYouBlock ? ( 2184 + /* Loading state - checking for You block */ 2185 + <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}> 2186 + <ActivityIndicator size="large" color={theme.colors.text.primary} /> 2187 + </View> 2188 + ) : !hasYouBlock ? ( 2189 + /* Empty state - no You block */ 2190 + <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: 40 }}> 2191 + <Text style={{ 2192 + fontSize: 24, 2193 + fontWeight: 'bold', 2194 + color: theme.colors.text.primary, 2195 + marginBottom: 40, 2196 + textAlign: 'center', 2197 + }}> 2198 + Want to understand yourself? 2199 + </Text> 2200 + <TouchableOpacity 2201 + onPress={createYouBlock} 2202 + disabled={isCreatingYouBlock} 2203 + style={{ 2204 + width: 80, 2205 + height: 80, 2206 + borderRadius: 40, 2207 + backgroundColor: theme.colors.background.tertiary, 2208 + borderWidth: 2, 2209 + borderColor: theme.colors.border.primary, 2210 + justifyContent: 'center', 2211 + alignItems: 'center', 2212 + }} 2213 + > 2214 + {isCreatingYouBlock ? ( 2215 + <ActivityIndicator size="large" color={theme.colors.text.primary} /> 2216 + ) : ( 2217 + <Ionicons name="add" size={48} color={theme.colors.text.primary} /> 2218 + )} 2219 + </TouchableOpacity> 2220 + </View> 2221 + ) : ( 2222 + /* You block exists - show content */ 2223 + <ScrollView 2224 + style={{ flex: 1 }} 2225 + contentContainerStyle={{ 2226 + maxWidth: 700, 2227 + width: '100%', 2228 + alignSelf: 'center', 2229 + padding: 20, 2230 + }} 2231 + > 2232 + <Markdown style={createMarkdownStyles({ isUser: false, isDark: colorScheme === 'dark' })}> 2233 + {youBlockContent} 2234 + </Markdown> 2235 + </ScrollView> 2236 + )} 2237 + </View> 2238 + 2239 + {/* Chat View */} 2240 + <View style={{ display: currentView === 'chat' ? 'flex' : 'none', flex: 1 }}> 2241 + {/* Messages */} 2242 + <View style={styles.messagesContainer} onLayout={handleMessagesLayout}> 2243 + <FlatList 2244 + ref={scrollViewRef} 2245 + data={displayMessages} 2246 + renderItem={renderMessage} 2247 + keyExtractor={keyExtractor} 2248 + extraData={{ showCompaction, expandedReasoning, expandedCompaction, copiedMessageId }} 2249 + onScroll={handleScroll} 2250 + onContentSizeChange={handleContentSizeChange} 2251 + maintainVisibleContentPosition={{ 2252 + minIndexForVisible: 0, 2253 + autoscrollToTopThreshold: 10, 2254 + }} 2255 + windowSize={50} 2256 + removeClippedSubviews={false} 2257 + maxToRenderPerBatch={20} 2258 + updateCellsBatchingPeriod={50} 2259 + initialNumToRender={100} 2260 + contentContainerStyle={[ 2261 + styles.messagesList, 2262 + displayMessages.length === 0 && { flexGrow: 1 }, 2263 + { 2264 + paddingBottom: 120 2265 + } 2266 + ]} 2267 + ListHeaderComponent={ 2268 + hasMoreBefore ? ( 2269 + <TouchableOpacity onPress={loadMoreMessages} style={styles.loadMoreButton}> 2270 + {isLoadingMore ? ( 2271 + <ActivityIndicator size="small" color={theme.colors.text.secondary} /> 2272 + ) : ( 2273 + <Text style={styles.loadMoreText}>Load more messages</Text> 2274 + )} 2275 + </TouchableOpacity> 2276 + ) : null 2277 + } 2278 + ListFooterComponent={ 2279 + <> 2280 + {isStreaming && ( 2281 + <Animated.View style={[styles.assistantFullWidthContainer, { minHeight: spacerHeightAnim, opacity: statusFadeAnim }]}> 2282 + {/* Streaming Block - show completed blocks + current stream content */} 2283 + 2284 + {/* Show completed blocks first (in chronological order) */} 2285 + {completedStreamBlocks.map((block, index) => { 2286 + if (block.type === 'reasoning') { 2287 + return ( 2288 + <React.Fragment key={`completed-${index}`}> 2289 + <LiveStatusIndicator status="thought" isDark={colorScheme === 'dark'} /> 2290 + <View style={styles.reasoningStreamingContainer}> 2291 + <Text style={styles.reasoningStreamingText}>{block.content}</Text> 2292 + </View> 2293 + </React.Fragment> 2294 + ); 2295 + } else if (block.type === 'assistant_message') { 2296 + return ( 2297 + <React.Fragment key={`completed-${index}`}> 2298 + <LiveStatusIndicator status="saying" isDark={colorScheme === 'dark'} /> 2299 + <View style={{ flex: 1 }}> 2300 + <MessageContent 2301 + content={block.content} 2302 + isUser={false} 2303 + isDark={colorScheme === 'dark'} 2304 + /> 2305 + </View> 2306 + <View style={styles.messageSeparator} /> 2307 + </React.Fragment> 2308 + ); 2309 + } 2310 + return null; 2311 + })} 2312 + 2313 + {/* Show current reasoning being accumulated */} 2314 + {currentStream.reasoning && ( 2315 + <> 2316 + <LiveStatusIndicator status="thought" /> 2317 + <View style={styles.reasoningStreamingContainer}> 2318 + <Text style={styles.reasoningStreamingText}>{currentStream.reasoning}</Text> 2319 + </View> 2320 + </> 2321 + )} 2322 + 2323 + {/* Show tool calls if we have any */} 2324 + {currentStream.toolCalls.map((toolCall) => ( 2325 + <View key={toolCall.id} style={styles.toolCallStreamingContainer}> 2326 + <ToolCallItem 2327 + callText={toolCall.args} 2328 + hasResult={false} 2329 + isDark={colorScheme === 'dark'} 2330 + /> 2331 + </View> 2332 + ))} 2333 + 2334 + {/* Show current assistant message being accumulated */} 2335 + {currentStream.assistantMessage && ( 2336 + <> 2337 + <View style={currentStream.toolCalls.length > 0 ? { marginTop: 16 } : undefined}> 2338 + <LiveStatusIndicator status="saying" isDark={colorScheme === 'dark'} /> 2339 + </View> 2340 + <View style={{ flex: 1 }}> 2341 + <MessageContent 2342 + content={currentStream.assistantMessage} 2343 + isUser={false} 2344 + isDark={colorScheme === 'dark'} 2345 + /> 2346 + </View> 2347 + <View style={styles.messageSeparator} /> 2348 + </> 2349 + )} 2350 + 2351 + {/* Show thinking indicator if nothing else to show */} 2352 + {completedStreamBlocks.length === 0 && !currentStream.reasoning && !currentStream.assistantMessage && currentStream.toolCalls.length === 0 && ( 2353 + <LiveStatusIndicator status="thinking" isDark={colorScheme === 'dark'} /> 2354 + )} 2355 + </Animated.View> 2356 + )} 2357 + </> 2358 + } 2359 + ListEmptyComponent={ 2360 + isLoadingMessages ? ( 2361 + <View style={styles.emptyContainer}> 2362 + <ActivityIndicator size="large" color={theme.colors.text.secondary} /> 2363 + </View> 2364 + ) : null 2365 + } 2366 + /> 2367 + 2368 + {/* Scroll to newest message button */} 2369 + {showScrollToBottom && ( 2370 + <TouchableOpacity onPress={scrollToBottom} style={styles.scrollToBottomButton}> 2371 + <Ionicons 2372 + name="arrow-down" 2373 + size={24} 2374 + color="#000" 2375 + /> 2376 + </TouchableOpacity> 2377 + )} 2378 + </View> 2379 + 2380 + {/* Input */} 2381 + <View 2382 + style={[ 2383 + styles.inputContainer, 2384 + { 2385 + paddingBottom: Math.max(insets.bottom, 16), 2386 + marginBottom: Platform.OS === 'android' && isKeyboardVisible ? 64 : 0 2387 + }, 2388 + displayMessages.length === 0 && styles.inputContainerCentered 2389 + ]} 2390 + onLayout={handleInputLayout} 2391 + > 2392 + <View style={styles.inputCentered}> 2393 + {/* Empty state intro - shown above input when chat is empty */} 2394 + {displayMessages.length === 0 && ( 2395 + <View style={styles.emptyStateIntro}> 2396 + <Animated.Text 2397 + style={{ 2398 + fontSize: 72, 2399 + fontFamily: 'Lexend_700Bold', 2400 + color: rainbowAnimValue.interpolate({ 2401 + inputRange: [0, 0.2, 0.4, 0.6, 0.8, 1], 2402 + outputRange: ['#FF6B6B', '#FFD93D', '#6BCF7F', '#4D96FF', '#9D4EDD', '#FF6B6B'] 2403 + }), 2404 + marginBottom: 16, 2405 + textAlign: 'center', 2406 + }} 2407 + > 2408 + co 2409 + </Animated.Text> 2410 + <Text style={[styles.emptyText, { fontSize: 18, lineHeight: 28, marginBottom: 32, color: theme.colors.text.primary }]}> 2411 + I'm co, your thinking partner. 2412 + </Text> 2413 + </View> 2414 + )} 2415 + {/* Image preview section */} 2416 + {selectedImages.length > 0 && ( 2417 + <View style={styles.imagePreviewContainer}> 2418 + {selectedImages.map((img, index) => ( 2419 + <View key={index} style={styles.imagePreviewWrapper}> 2420 + <Image source={{ uri: img.uri }} style={styles.imagePreview} /> 2421 + <TouchableOpacity 2422 + onPress={() => removeImage(index)} 2423 + style={styles.removeImageButton} 2424 + > 2425 + <Ionicons name="close-circle" size={24} color="#fff" /> 2426 + </TouchableOpacity> 2427 + </View> 2428 + ))} 2429 + </View> 2430 + )} 2431 + 2432 + <Animated.View style={[ 2433 + styles.inputWrapper, 2434 + inputWrapperStyle, 2435 + isInputFocused && { 2436 + borderColor: rainbowAnimValue.interpolate({ 2437 + inputRange: [0, 0.2, 0.4, 0.6, 0.8, 1], 2438 + outputRange: ['#FF6B6B', '#FFD93D', '#6BCF7F', '#4D96FF', '#9D4EDD', '#FF6B6B'] 2439 + }), 2440 + shadowColor: rainbowAnimValue.interpolate({ 2441 + inputRange: [0, 0.2, 0.4, 0.6, 0.8, 1], 2442 + outputRange: ['#FF6B6B', '#FFD93D', '#6BCF7F', '#4D96FF', '#9D4EDD', '#FF6B6B'] 2443 + }), 2444 + shadowOpacity: 0.4, 2445 + shadowRadius: 16, 2446 + } 2447 + ]}> 2448 + <TouchableOpacity 2449 + onPress={pickAndUploadFile} 2450 + style={styles.fileButton} 2451 + disabled={isSendingMessage || isUploadingFile} 2452 + > 2453 + <Ionicons name="attach-outline" size={20} color="#666666" /> 2454 + </TouchableOpacity> 2455 + <TouchableOpacity 2456 + onPress={pickImage} 2457 + style={styles.imageButton} 2458 + disabled={isSendingMessage} 2459 + > 2460 + <Ionicons name="image-outline" size={20} color="#666666" /> 2461 + </TouchableOpacity> 2462 + <MessageInput 2463 + onTextChange={handleTextChange} 2464 + onSendMessage={handleSendFromInput} 2465 + isSendingMessage={isSendingMessage} 2466 + colorScheme={colorScheme} 2467 + onFocusChange={handleInputFocusChange} 2468 + clearTrigger={clearInputTrigger} 2469 + /> 2470 + <TouchableOpacity 2471 + onPress={handleSendFromInput} 2472 + style={[styles.sendButton, sendButtonStyle]} 2473 + disabled={(!hasInputText && selectedImages.length === 0) || isSendingMessage} 2474 + > 2475 + {isSendingMessage ? ( 2476 + <ActivityIndicator size="small" color={colorScheme === 'dark' ? '#fff' : '#000'} /> 2477 + ) : ( 2478 + <Ionicons 2479 + name="arrow-up" 2480 + size={20} 2481 + color={sendIconColor} 2482 + /> 2483 + )} 2484 + </TouchableOpacity> 2485 + </Animated.View> 2486 + </View> 2487 + </View> 2488 + </View> 2489 + 2490 + {/* Knowledge View */} 2491 + <View style={[styles.memoryViewContainer, { display: currentView === 'knowledge' ? 'flex' : 'none', backgroundColor: theme.colors.background.primary }]}> 2492 + {/* Knowledge Tabs */} 2493 + <View style={[styles.knowledgeTabs, { backgroundColor: theme.colors.background.secondary, borderBottomColor: theme.colors.border.primary }]}> 2494 + <TouchableOpacity 2495 + style={[ 2496 + styles.knowledgeTab, 2497 + knowledgeTab === 'core' && { borderBottomColor: theme.colors.text.primary, borderBottomWidth: 2 } 2498 + ]} 2499 + onPress={() => setKnowledgeTab('core')} 2500 + > 2501 + <Text style={[ 2502 + styles.knowledgeTabText, 2503 + { color: knowledgeTab === 'core' ? theme.colors.text.primary : theme.colors.text.tertiary } 2504 + ]}>Core Memory</Text> 2505 + </TouchableOpacity> 2506 + <TouchableOpacity 2507 + style={[ 2508 + styles.knowledgeTab, 2509 + knowledgeTab === 'archival' && { borderBottomColor: theme.colors.text.primary, borderBottomWidth: 2 } 2510 + ]} 2511 + onPress={() => setKnowledgeTab('archival')} 2512 + > 2513 + <Text style={[ 2514 + styles.knowledgeTabText, 2515 + { color: knowledgeTab === 'archival' ? theme.colors.text.primary : theme.colors.text.tertiary } 2516 + ]}>Archival Memory</Text> 2517 + </TouchableOpacity> 2518 + <TouchableOpacity 2519 + style={[ 2520 + styles.knowledgeTab, 2521 + knowledgeTab === 'files' && { borderBottomColor: theme.colors.text.primary, borderBottomWidth: 2 } 2522 + ]} 2523 + onPress={() => setKnowledgeTab('files')} 2524 + > 2525 + <Text style={[ 2526 + styles.knowledgeTabText, 2527 + { color: knowledgeTab === 'files' ? theme.colors.text.primary : theme.colors.text.tertiary } 2528 + ]}>Files</Text> 2529 + </TouchableOpacity> 2530 + </View> 2531 + 2532 + {/* Search bars */} 2533 + {knowledgeTab === 'core' && ( 2534 + <View style={styles.memorySearchContainer}> 2535 + <Ionicons name="search" size={20} color={theme.colors.text.tertiary} style={styles.memorySearchIcon} /> 2536 + <TextInput 2537 + style={[styles.memorySearchInput, { 2538 + color: theme.colors.text.primary, 2539 + backgroundColor: theme.colors.background.tertiary, 2540 + borderColor: theme.colors.border.primary, 2541 + }]} 2542 + placeholder="Search memory blocks..." 2543 + placeholderTextColor={theme.colors.text.tertiary} 2544 + value={memorySearchQuery} 2545 + onChangeText={setMemorySearchQuery} 2546 + /> 2547 + </View> 2548 + )} 2549 + 2550 + {knowledgeTab === 'archival' && ( 2551 + <View style={styles.memorySearchContainer}> 2552 + <Ionicons name="search" size={20} color={theme.colors.text.tertiary} style={styles.memorySearchIcon} /> 2553 + <TextInput 2554 + style={[styles.memorySearchInput, { 2555 + color: theme.colors.text.primary, 2556 + backgroundColor: theme.colors.background.tertiary, 2557 + borderColor: theme.colors.border.primary, 2558 + paddingRight: passageSearchQuery ? 96 : 60, 2559 + }]} 2560 + placeholder="Search archival memory..." 2561 + placeholderTextColor={theme.colors.text.tertiary} 2562 + value={passageSearchQuery} 2563 + onChangeText={setPassageSearchQuery} 2564 + onSubmitEditing={() => loadPassages(true)} 2565 + /> 2566 + {passageSearchQuery && ( 2567 + <TouchableOpacity 2568 + style={{ position: 'absolute', right: 64, padding: 8 }} 2569 + onPress={() => { 2570 + setPassageSearchQuery(''); 2571 + loadPassages(true); 2572 + }} 2573 + > 2574 + <Ionicons name="close-circle" size={20} color={theme.colors.text.tertiary} /> 2575 + </TouchableOpacity> 2576 + )} 2577 + <TouchableOpacity 2578 + style={{ position: 'absolute', right: 28, padding: 8 }} 2579 + onPress={() => setIsCreatingPassage(true)} 2580 + > 2581 + <Ionicons name="add-circle-outline" size={24} color={theme.colors.text.primary} /> 2582 + </TouchableOpacity> 2583 + </View> 2584 + )} 2585 + 2586 + {/* Knowledge blocks grid */} 2587 + <View style={styles.memoryBlocksGrid}> 2588 + {knowledgeTab === 'files' ? ( 2589 + /* Files view */ 2590 + <> 2591 + <View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 8, paddingVertical: 12 }}> 2592 + <Text style={[styles.memorySectionTitle, { color: theme.colors.text.secondary, marginBottom: 0 }]}>Uploaded Files</Text> 2593 + <TouchableOpacity 2594 + onPress={pickAndUploadFile} 2595 + disabled={isUploadingFile} 2596 + style={{ padding: 4 }} 2597 + > 2598 + {isUploadingFile ? ( 2599 + <ActivityIndicator size="small" color={theme.colors.text.secondary} /> 2600 + ) : ( 2601 + <Ionicons name="add-circle-outline" size={24} color={theme.colors.text.primary} /> 2602 + )} 2603 + </TouchableOpacity> 2604 + </View> 2605 + {uploadProgress && ( 2606 + <View style={{ marginHorizontal: 8, marginBottom: 12, paddingVertical: 8, paddingHorizontal: 12, backgroundColor: theme.colors.background.tertiary, borderRadius: 8 }}> 2607 + <Text style={{ color: theme.colors.text.secondary, fontSize: 14 }}>{uploadProgress}</Text> 2608 + </View> 2609 + )} 2610 + {isLoadingFiles ? ( 2611 + <View style={styles.memoryLoadingContainer}> 2612 + <ActivityIndicator size="large" color={theme.colors.text.secondary} /> 2613 + </View> 2614 + ) : filesError ? ( 2615 + <Text style={[styles.errorText, { textAlign: 'center', marginTop: 40 }]}>{filesError}</Text> 2616 + ) : folderFiles.length === 0 ? ( 2617 + <View style={styles.memoryEmptyState}> 2618 + <Ionicons name="folder-outline" size={64} color={theme.colors.text.tertiary} style={{ opacity: 0.3 }} /> 2619 + <Text style={[styles.memoryEmptyText, { color: theme.colors.text.tertiary }]}>No files uploaded yet</Text> 2620 + </View> 2621 + ) : ( 2622 + <FlatList 2623 + data={folderFiles} 2624 + keyExtractor={(item) => item.id} 2625 + contentContainerStyle={styles.memoryBlocksContent} 2626 + renderItem={({ item }) => ( 2627 + <View 2628 + style={[styles.memoryBlockCard, { 2629 + backgroundColor: theme.colors.background.secondary, 2630 + borderColor: theme.colors.border.primary, 2631 + flexDirection: 'row', 2632 + justifyContent: 'space-between', 2633 + alignItems: 'center', 2634 + minHeight: 'auto' 2635 + }]} 2636 + > 2637 + <View style={{ flex: 1 }}> 2638 + <Text style={[styles.memoryBlockCardLabel, { color: theme.colors.text.primary }]} numberOfLines={1}> 2639 + {item.fileName || item.name || 'Untitled'} 2640 + </Text> 2641 + <Text style={[styles.memoryBlockCardPreview, { color: theme.colors.text.secondary, fontSize: 12 }]}> 2642 + {new Date(item.createdAt || item.created_at).toLocaleDateString()} 2643 + </Text> 2644 + </View> 2645 + <TouchableOpacity 2646 + onPress={() => deleteFile(item.id, item.fileName || item.name)} 2647 + style={{ padding: 8 }} 2648 + > 2649 + <Ionicons name="trash-outline" size={20} color={theme.colors.status.error} /> 2650 + </TouchableOpacity> 2651 + </View> 2652 + )} 2653 + /> 2654 + )} 2655 + </> 2656 + ) : knowledgeTab === 'archival' ? ( 2657 + /* Archival Memory view */ 2658 + isLoadingPassages ? ( 2659 + <View style={styles.memoryLoadingContainer}> 2660 + <ActivityIndicator size="large" color={theme.colors.text.secondary} /> 2661 + </View> 2662 + ) : passagesError ? ( 2663 + <Text style={[styles.errorText, { textAlign: 'center', marginTop: 40 }]}>{passagesError}</Text> 2664 + ) : passages.length === 0 ? ( 2665 + <View style={styles.memoryEmptyState}> 2666 + <Ionicons name="archive-outline" size={64} color={theme.colors.text.tertiary} style={{ opacity: 0.3 }} /> 2667 + <Text style={[styles.memoryEmptyText, { color: theme.colors.text.tertiary }]}>No archival memories yet</Text> 2668 + </View> 2669 + ) : ( 2670 + <FlatList 2671 + data={passages} 2672 + keyExtractor={(item) => item.id} 2673 + contentContainerStyle={styles.memoryBlocksContent} 2674 + renderItem={({ item }) => ( 2675 + <View 2676 + style={[styles.memoryBlockCard, { 2677 + backgroundColor: theme.colors.background.secondary, 2678 + borderColor: theme.colors.border.primary, 2679 + }]} 2680 + > 2681 + <View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}> 2682 + <Text style={[styles.memoryBlockCardPreview, { color: theme.colors.text.tertiary, fontSize: 11, flex: 1 }]}> 2683 + {new Date(item.created_at).toLocaleString()} 2684 + </Text> 2685 + <View style={{ flexDirection: 'row', gap: 8 }}> 2686 + <TouchableOpacity 2687 + onPress={() => { 2688 + setSelectedPassage(item); 2689 + setIsEditingPassage(true); 2690 + }} 2691 + style={{ padding: 4 }} 2692 + > 2693 + <Ionicons name="create-outline" size={18} color={theme.colors.text.secondary} /> 2694 + </TouchableOpacity> 2695 + <TouchableOpacity 2696 + onPress={() => deletePassage(item.id)} 2697 + style={{ padding: 4 }} 2698 + > 2699 + <Ionicons name="trash-outline" size={18} color={theme.colors.status.error} /> 2700 + </TouchableOpacity> 2701 + </View> 2702 + </View> 2703 + <Text 2704 + style={[styles.memoryBlockCardPreview, { color: theme.colors.text.primary }]} 2705 + numberOfLines={6} 2706 + > 2707 + {item.text} 2708 + </Text> 2709 + {item.tags && item.tags.length > 0 && ( 2710 + <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8 }}> 2711 + {item.tags.map((tag, idx) => ( 2712 + <View key={idx} style={{ backgroundColor: theme.colors.background.tertiary, paddingHorizontal: 8, paddingVertical: 4, borderRadius: 4 }}> 2713 + <Text style={{ color: theme.colors.text.secondary, fontSize: 11 }}>{tag}</Text> 2714 + </View> 2715 + ))} 2716 + </View> 2717 + )} 2718 + </View> 2719 + )} 2720 + ListFooterComponent={ 2721 + hasMorePassages ? ( 2722 + <TouchableOpacity 2723 + onPress={() => loadPassages(false)} 2724 + style={{ padding: 16, alignItems: 'center' }} 2725 + > 2726 + <Text style={{ color: theme.colors.text.secondary }}>Load more...</Text> 2727 + </TouchableOpacity> 2728 + ) : null 2729 + } 2730 + /> 2731 + ) 2732 + ) : isLoadingBlocks ? ( 2733 + <View style={styles.memoryLoadingContainer}> 2734 + <ActivityIndicator size="large" color={theme.colors.text.secondary} /> 2735 + </View> 2736 + ) : blocksError ? ( 2737 + <Text style={[styles.errorText, { textAlign: 'center', marginTop: 40 }]}>{blocksError}</Text> 2738 + ) : ( 2739 + <FlatList 2740 + data={memoryBlocks.filter(block => { 2741 + // Core memory: show all blocks 2742 + // Filter by search query 2743 + if (memorySearchQuery) { 2744 + return block.label.toLowerCase().includes(memorySearchQuery.toLowerCase()) || 2745 + block.value.toLowerCase().includes(memorySearchQuery.toLowerCase()); 2746 + } 2747 + 2748 + return true; 2749 + })} 2750 + numColumns={isDesktop ? 2 : 1} 2751 + key={isDesktop ? 'desktop' : 'mobile'} 2752 + keyExtractor={(item) => item.id || item.label} 2753 + contentContainerStyle={styles.memoryBlocksContent} 2754 + renderItem={({ item }) => ( 2755 + <TouchableOpacity 2756 + style={[styles.memoryBlockCard, { 2757 + backgroundColor: theme.colors.background.secondary, 2758 + borderColor: theme.colors.border.primary 2759 + }]} 2760 + onPress={() => setSelectedBlock(item)} 2761 + > 2762 + <View style={styles.memoryBlockCardHeader}> 2763 + <Text style={[styles.memoryBlockCardLabel, { color: theme.colors.text.primary }]}> 2764 + {item.label} 2765 + </Text> 2766 + <Text style={[styles.memoryBlockCardCount, { color: theme.colors.text.tertiary }]}> 2767 + {item.value.length} chars 2768 + </Text> 2769 + </View> 2770 + <Text 2771 + style={[styles.memoryBlockCardPreview, { color: theme.colors.text.secondary }]} 2772 + numberOfLines={4} 2773 + > 2774 + {item.value || 'Empty'} 2775 + </Text> 2776 + </TouchableOpacity> 2777 + )} 2778 + ListEmptyComponent={ 2779 + <View style={styles.memoryEmptyState}> 2780 + <Ionicons name="library-outline" size={64} color={theme.colors.text.tertiary} style={{ opacity: 0.3 }} /> 2781 + <Text style={[styles.memoryEmptyText, { color: theme.colors.text.secondary }]}> 2782 + {memorySearchQuery ? 'No memory blocks found' : 'No memory blocks yet'} 2783 + </Text> 2784 + </View> 2785 + } 2786 + /> 2787 + )} 2788 + </View> 2789 + </View> 2790 + 2791 + {/* Settings View */} 2792 + <View style={[styles.memoryViewContainer, { display: currentView === 'settings' ? 'flex' : 'none', backgroundColor: theme.colors.background.primary }]}> 2793 + <View style={[styles.settingsHeader, { backgroundColor: theme.colors.background.secondary, borderBottomColor: theme.colors.border.primary }]}> 2794 + <Text style={[styles.settingsTitle, { color: theme.colors.text.primary }]}>Settings</Text> 2795 + </View> 2796 + 2797 + <View style={styles.settingsContent}> 2798 + {/* Show Compaction Setting */} 2799 + <View style={[styles.settingItem, { borderBottomColor: theme.colors.border.primary }]}> 2800 + <View style={styles.settingInfo}> 2801 + <Text style={[styles.settingLabel, { color: theme.colors.text.primary }]}>Show Compaction</Text> 2802 + <Text style={[styles.settingDescription, { color: theme.colors.text.secondary }]}> 2803 + Display compaction bars when conversation history is summarized 2804 + </Text> 2805 + </View> 2806 + <TouchableOpacity 2807 + style={[styles.toggle, showCompaction && styles.toggleActive]} 2808 + onPress={() => setShowCompaction(!showCompaction)} 2809 + > 2810 + <View style={[styles.toggleThumb, showCompaction && styles.toggleThumbActive]} /> 2811 + </TouchableOpacity> 2812 + </View> 2813 + </View> 2814 + </View> 2815 + 2816 + {/* Knowledge block viewer - right pane on desktop */} 2817 + {isDesktop && selectedBlock && ( 2818 + <MemoryBlockViewer 2819 + block={selectedBlock} 2820 + onClose={() => setSelectedBlock(null)} 2821 + isDark={colorScheme === 'dark'} 2822 + isDesktop={isDesktop} 2823 + /> 2824 + )} 2825 + </KeyboardAvoidingView> 2826 + </View> 2827 + 2828 + {/* Knowledge block viewer - overlay on mobile */} 2829 + {!isDesktop && selectedBlock && ( 2830 + <MemoryBlockViewer 2831 + block={selectedBlock} 2832 + onClose={() => setSelectedBlock(null)} 2833 + isDark={colorScheme === 'dark'} 2834 + isDesktop={isDesktop} 2835 + /> 2836 + )} 2837 + 2838 + {/* Create/Edit Passage Modal */} 2839 + {(isCreatingPassage || isEditingPassage) && ( 2840 + <View style={styles.modalOverlay}> 2841 + <View style={[styles.modalContent, { backgroundColor: theme.colors.background.primary, borderColor: theme.colors.border.primary }]}> 2842 + <View style={styles.modalHeader}> 2843 + <Text style={[styles.modalTitle, { color: theme.colors.text.primary }]}> 2844 + {isCreatingPassage ? 'Create Passage' : 'Edit Passage'} 2845 + </Text> 2846 + <TouchableOpacity 2847 + onPress={() => { 2848 + if (isSavingPassage) return; 2849 + setIsCreatingPassage(false); 2850 + setIsEditingPassage(false); 2851 + setSelectedPassage(null); 2852 + }} 2853 + disabled={isSavingPassage} 2854 + > 2855 + <Ionicons name="close" size={24} color={theme.colors.text.primary} style={{ opacity: isSavingPassage ? 0.5 : 1 }} /> 2856 + </TouchableOpacity> 2857 + </View> 2858 + <View style={styles.modalBody}> 2859 + <Text style={[styles.inputLabel, { color: theme.colors.text.secondary }]}>Text</Text> 2860 + <TextInput 2861 + style={[styles.textArea, { color: theme.colors.text.primary, backgroundColor: theme.colors.background.secondary, borderColor: theme.colors.border.primary }]} 2862 + multiline 2863 + numberOfLines={6} 2864 + defaultValue={selectedPassage?.text || ''} 2865 + placeholder="Enter passage text..." 2866 + placeholderTextColor={theme.colors.text.tertiary} 2867 + onChangeText={(text) => { 2868 + if (selectedPassage) { 2869 + setSelectedPassage({ ...selectedPassage, text }); 2870 + } else { 2871 + setSelectedPassage({ text, id: '', created_at: new Date().toISOString() } as Passage); 2872 + } 2873 + }} 2874 + /> 2875 + <Text style={[styles.inputLabel, { color: theme.colors.text.secondary, marginTop: 16 }]}>Tags (comma-separated)</Text> 2876 + <TextInput 2877 + style={[styles.textInput, { color: theme.colors.text.primary, backgroundColor: theme.colors.background.secondary, borderColor: theme.colors.border.primary }]} 2878 + defaultValue={selectedPassage?.tags?.join(', ') || ''} 2879 + placeholder="tag1, tag2, tag3" 2880 + placeholderTextColor={theme.colors.text.tertiary} 2881 + onChangeText={(text) => { 2882 + const tags = text.split(',').map(t => t.trim()).filter(t => t); 2883 + if (selectedPassage) { 2884 + setSelectedPassage({ ...selectedPassage, tags }); 2885 + } else { 2886 + setSelectedPassage({ text: '', tags, id: '', created_at: new Date().toISOString() } as Passage); 2887 + } 2888 + }} 2889 + /> 2890 + </View> 2891 + <View style={styles.modalFooter}> 2892 + <TouchableOpacity 2893 + style={[styles.modalButton, styles.modalButtonSecondary, { borderColor: theme.colors.border.primary, opacity: isSavingPassage ? 0.5 : 1 }]} 2894 + onPress={() => { 2895 + if (isSavingPassage) return; 2896 + setIsCreatingPassage(false); 2897 + setIsEditingPassage(false); 2898 + setSelectedPassage(null); 2899 + }} 2900 + disabled={isSavingPassage} 2901 + > 2902 + <Text style={[styles.modalButtonText, { color: theme.colors.text.primary }]}>Cancel</Text> 2903 + </TouchableOpacity> 2904 + <TouchableOpacity 2905 + style={[styles.modalButton, styles.modalButtonPrimary, { backgroundColor: theme.colors.text.primary, opacity: isSavingPassage ? 0.7 : 1 }]} 2906 + onPress={async () => { 2907 + if (isSavingPassage) return; 2908 + if (!selectedPassage?.text) { 2909 + Alert.alert('Error', 'Please enter passage text'); 2910 + return; 2911 + } 2912 + setIsSavingPassage(true); 2913 + try { 2914 + if (isEditingPassage && selectedPassage.id) { 2915 + await modifyPassage(selectedPassage.id, selectedPassage.text, selectedPassage.tags); 2916 + } else { 2917 + await createPassage(selectedPassage.text, selectedPassage.tags); 2918 + } 2919 + setIsCreatingPassage(false); 2920 + setIsEditingPassage(false); 2921 + setSelectedPassage(null); 2922 + } catch (error) { 2923 + console.error('Error saving passage:', error); 2924 + } finally { 2925 + setIsSavingPassage(false); 2926 + } 2927 + }} 2928 + disabled={isSavingPassage} 2929 + > 2930 + {isSavingPassage ? ( 2931 + <ActivityIndicator size="small" color={theme.colors.background.primary} /> 2932 + ) : ( 2933 + <Text style={[styles.modalButtonText, { color: theme.colors.background.primary }]}> 2934 + {isCreatingPassage ? 'Create' : 'Save'} 2935 + </Text> 2936 + )} 2937 + </TouchableOpacity> 2938 + </View> 2939 + </View> 2940 + </View> 2941 + )} 2942 + 2943 + {/* Approval modal */} 2944 + <Modal 2945 + visible={approvalVisible} 2946 + animationType="fade" 2947 + transparent={true} 2948 + onRequestClose={() => setApprovalVisible(false)} 2949 + > 2950 + <View style={styles.approvalOverlay}> 2951 + <View style={styles.approvalContainer}> 2952 + <Text style={styles.approvalTitle}>Tool Approval Required</Text> 2953 + 2954 + {approvalData?.toolName && ( 2955 + <Text style={styles.approvalTool}>Tool: {approvalData.toolName}</Text> 2956 + )} 2957 + 2958 + {approvalData?.reasoning && ( 2959 + <View style={styles.approvalReasoning}> 2960 + <Text style={styles.approvalReasoningLabel}>Reasoning:</Text> 2961 + <Text style={styles.approvalReasoningText}>{approvalData.reasoning}</Text> 2962 + </View> 2963 + )} 2964 + 2965 + <TextInput 2966 + style={styles.approvalInput} 2967 + placeholder="Optional reason..." 2968 + placeholderTextColor={theme.colors.text.tertiary} 2969 + value={approvalReason} 2970 + onChangeText={setApprovalReason} 2971 + multiline 2972 + /> 2973 + 2974 + <View style={styles.approvalButtons}> 2975 + <TouchableOpacity 2976 + style={[styles.approvalButton, styles.denyButton]} 2977 + onPress={() => handleApproval(false)} 2978 + disabled={isApproving} 2979 + > 2980 + <Text style={styles.approvalButtonText}>Deny</Text> 2981 + </TouchableOpacity> 2982 + 2983 + <TouchableOpacity 2984 + style={[styles.approvalButton, styles.approveButton]} 2985 + onPress={() => handleApproval(true)} 2986 + disabled={isApproving} 2987 + > 2988 + {isApproving ? ( 2989 + <ActivityIndicator size="small" color="#fff" /> 2990 + ) : ( 2991 + <Text style={styles.approvalButtonText}>Approve</Text> 2992 + )} 2993 + </TouchableOpacity> 2994 + </View> 2995 + </View> 2996 + </View> 2997 + </Modal> 2998 + 82 2999 <StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} /> 83 - <ChatScreen theme={theme} /> 84 - </> 3000 + </View> 85 3001 ); 86 3002 } 87 3003 88 - // Wrap app with providers and error boundary 89 3004 export default function App() { 90 3005 return ( 91 - <ErrorBoundary> 92 - <SafeAreaProvider> 3006 + <View style={{ flex: 1, backgroundColor: darkTheme.colors.background.primary }}> 3007 + <SafeAreaProvider style={{ flex: 1, backgroundColor: darkTheme.colors.background.primary }}> 93 3008 <CoApp /> 94 3009 </SafeAreaProvider> 95 - </ErrorBoundary> 3010 + </View> 96 3011 ); 97 3012 } 98 3013 99 3014 const styles = StyleSheet.create({ 3015 + container: { 3016 + flex: 1, 3017 + flexDirection: 'row', 3018 + backgroundColor: darkTheme.colors.background.primary, 3019 + }, 3020 + mainContent: { 3021 + flex: 1, 3022 + flexDirection: 'column', 3023 + }, 3024 + chatRow: { 3025 + flex: 1, 3026 + flexDirection: 'row', 3027 + }, 100 3028 loadingContainer: { 101 3029 flex: 1, 102 3030 justifyContent: 'center', 103 3031 alignItems: 'center', 3032 + backgroundColor: darkTheme.colors.background.primary, 3033 + }, 3034 + loadingText: { 3035 + marginTop: 16, 3036 + fontSize: 16, 3037 + fontFamily: 'Lexend_400Regular', 3038 + color: darkTheme.colors.text.secondary, 3039 + }, 3040 + header: { 3041 + flexDirection: 'row', 3042 + alignItems: 'center', 3043 + paddingHorizontal: 16, 3044 + paddingTop: 12, 3045 + paddingBottom: 12, 3046 + backgroundColor: darkTheme.colors.background.secondary, 3047 + borderBottomWidth: 1, 3048 + borderBottomColor: darkTheme.colors.border.primary, 3049 + }, 3050 + viewSwitcher: { 3051 + flexDirection: 'row', 3052 + justifyContent: 'space-between', 3053 + alignItems: 'center', 3054 + paddingHorizontal: 16, 3055 + paddingVertical: 4, 3056 + maxWidth: 400, 3057 + alignSelf: 'center', 3058 + width: '100%', 3059 + }, 3060 + viewSwitcherButton: { 3061 + paddingVertical: 6, 3062 + paddingHorizontal: 16, 3063 + borderRadius: 8, 3064 + flex: 1, 3065 + alignItems: 'center', 3066 + justifyContent: 'center', 3067 + }, 3068 + viewSwitcherText: { 3069 + fontSize: 14, 3070 + fontFamily: 'Lexend_500Medium', 3071 + }, 3072 + menuButton: { 3073 + padding: 8, 3074 + }, 3075 + headerCenter: { 3076 + flex: 1, 3077 + alignItems: 'center', 3078 + }, 3079 + headerTitle: { 3080 + fontSize: 36, 3081 + fontFamily: 'Lexend_700Bold', 3082 + color: darkTheme.colors.text.primary, 3083 + }, 3084 + headerButton: { 3085 + padding: 8, 3086 + }, 3087 + headerButtonDisabled: { 3088 + opacity: 0.3, 3089 + }, 3090 + headerSpacer: { 3091 + width: 40, 3092 + }, 3093 + logoutButton: { 3094 + padding: 8, 3095 + }, 3096 + messagesContainer: { 3097 + flex: 1, 3098 + }, 3099 + messagesList: { 3100 + maxWidth: 700, 3101 + width: '100%', 3102 + alignSelf: 'center', 3103 + paddingBottom: 180, // Space for input at bottom (accounts for expanded input height) 3104 + }, 3105 + messageContainer: { 3106 + paddingHorizontal: 18, 3107 + paddingVertical: 12, 3108 + }, 3109 + userMessageContainer: { 3110 + alignItems: 'flex-end', 3111 + }, 3112 + assistantMessageContainer: { 3113 + alignItems: 'flex-start', 3114 + }, 3115 + assistantFullWidthContainer: { 3116 + paddingHorizontal: 18, 3117 + paddingVertical: 16, 3118 + width: '100%', 3119 + }, 3120 + assistantLabel: { 3121 + fontSize: 16, 3122 + fontFamily: 'Lexend_500Medium', 3123 + marginBottom: 8, 3124 + }, 3125 + messageBubble: { 3126 + maxWidth: 600, 3127 + padding: 12, 3128 + borderRadius: 24, 3129 + }, 3130 + userBubble: { 3131 + // Background color set dynamically per theme in render 3132 + }, 3133 + messageImagesContainer: { 3134 + flexDirection: 'row', 3135 + flexWrap: 'wrap', 3136 + gap: 8, 3137 + marginBottom: 8, 3138 + }, 3139 + messageImage: { 3140 + width: 200, 3141 + height: 200, 3142 + borderRadius: 12, 3143 + resizeMode: 'cover', 3144 + }, 3145 + assistantBubble: { 3146 + backgroundColor: darkTheme.colors.background.secondary, 3147 + borderWidth: 1, 3148 + borderColor: darkTheme.colors.border.primary, 3149 + }, 3150 + userMessageText: { 3151 + color: darkTheme.colors.background.primary, 3152 + fontSize: 18, 3153 + fontFamily: 'Lexend_400Regular', 3154 + lineHeight: 26, 3155 + }, 3156 + assistantMessageText: { 3157 + color: darkTheme.colors.text.primary, 3158 + fontSize: 18, 3159 + fontFamily: 'Lexend_400Regular', 3160 + lineHeight: 26, 3161 + }, 3162 + reasoningContainer: { 3163 + marginTop: 8, 3164 + paddingTop: 8, 3165 + borderTopWidth: 1, 3166 + borderTopColor: darkTheme.colors.border.primary, 3167 + }, 3168 + reasoningLabel: { 3169 + fontSize: 12, 3170 + fontFamily: 'Lexend_600SemiBold', 3171 + color: darkTheme.colors.text.secondary, 3172 + marginBottom: 4, 3173 + }, 3174 + reasoningText: { 3175 + fontSize: 12, 3176 + fontFamily: 'Lexend_400Regular', 3177 + color: darkTheme.colors.text.tertiary, 3178 + fontStyle: 'italic', 3179 + }, 3180 + loadMoreButton: { 3181 + padding: 16, 3182 + alignItems: 'center', 3183 + }, 3184 + loadMoreText: { 3185 + color: darkTheme.colors.text.secondary, 3186 + fontSize: 14, 3187 + fontFamily: 'Lexend_400Regular', 3188 + }, 3189 + messageSeparator: { 3190 + height: 16, 3191 + }, 3192 + emptyContainer: { 3193 + flex: 1, 3194 + justifyContent: 'center', 3195 + alignItems: 'center', 3196 + paddingHorizontal: 60, 3197 + paddingVertical: 80, 3198 + }, 3199 + emptyText: { 3200 + fontSize: 24, 3201 + fontFamily: 'Lexend_400Regular', 3202 + color: darkTheme.colors.text.primary, 3203 + textAlign: 'center', 3204 + lineHeight: 36, 3205 + }, 3206 + scrollToBottomButton: { 3207 + position: 'absolute', 3208 + bottom: 16, 3209 + right: 16, 3210 + width: 48, 3211 + height: 48, 3212 + borderRadius: 24, 3213 + backgroundColor: darkTheme.colors.interactive.primary, 3214 + justifyContent: 'center', 3215 + alignItems: 'center', 3216 + shadowColor: '#000', 3217 + shadowOffset: { width: 0, height: 2 }, 3218 + shadowOpacity: 0.25, 3219 + shadowRadius: 3.84, 3220 + elevation: 5, 3221 + }, 3222 + inputContainer: { 3223 + position: 'absolute', 3224 + bottom: 0, 3225 + left: 0, 3226 + right: 0, 3227 + paddingTop: 16, 3228 + paddingBottom: 24, 3229 + paddingHorizontal: 16, 3230 + alignItems: 'center', 3231 + }, 3232 + inputContainerCentered: { 3233 + top: 0, 3234 + bottom: 'auto', 3235 + justifyContent: 'center', 3236 + height: '100%', 3237 + }, 3238 + emptyStateIntro: { 3239 + alignItems: 'center', 3240 + marginBottom: 0, 3241 + }, 3242 + inputCentered: { 3243 + position: 'relative', 3244 + maxWidth: 700, 3245 + width: '100%', 3246 + }, 3247 + inputWrapper: { 3248 + position: 'relative', 3249 + flexDirection: 'row', 3250 + alignItems: 'flex-end', 3251 + }, 3252 + fileButton: { 3253 + position: 'absolute', 3254 + right: 88, 3255 + bottom: 8, 3256 + width: 32, 3257 + height: 32, 3258 + borderRadius: 16, 3259 + justifyContent: 'center', 3260 + alignItems: 'center', 3261 + zIndex: 1, 3262 + }, 3263 + imageButton: { 3264 + position: 'absolute', 3265 + right: 52, 3266 + bottom: 8, 3267 + width: 32, 3268 + height: 32, 3269 + borderRadius: 16, 3270 + justifyContent: 'center', 3271 + alignItems: 'center', 3272 + zIndex: 1, 3273 + }, 3274 + imagePreviewContainer: { 3275 + flexDirection: 'row', 3276 + flexWrap: 'wrap', 3277 + padding: 12, 3278 + gap: 8, 3279 + }, 3280 + imagePreviewWrapper: { 3281 + position: 'relative', 3282 + width: 80, 3283 + height: 80, 3284 + }, 3285 + imagePreview: { 3286 + width: 80, 3287 + height: 80, 3288 + borderRadius: 8, 3289 + resizeMode: 'cover', 3290 + }, 3291 + removeImageButton: { 3292 + position: 'absolute', 3293 + top: -8, 3294 + right: -8, 3295 + backgroundColor: 'rgba(0, 0, 0, 0.6)', 3296 + borderRadius: 12, 3297 + }, 3298 + sendButton: { 3299 + position: 'absolute', 3300 + right: 10, 3301 + bottom: 8, 3302 + width: 32, 3303 + height: 32, 3304 + borderRadius: 16, 3305 + justifyContent: 'center', 3306 + alignItems: 'center', 3307 + transition: 'all 0.2s ease', 3308 + }, 3309 + sidebarContainer: { 3310 + height: '100%', 3311 + backgroundColor: darkTheme.colors.background.secondary, 3312 + borderRightWidth: 1, 3313 + borderRightColor: darkTheme.colors.border.primary, 3314 + overflow: 'hidden', 3315 + }, 3316 + sidebarHeader: { 3317 + flexDirection: 'row', 3318 + justifyContent: 'space-between', 3319 + alignItems: 'center', 3320 + paddingHorizontal: 16, 3321 + paddingVertical: 16, 3322 + borderBottomWidth: 1, 3323 + borderBottomColor: darkTheme.colors.border.primary, 3324 + }, 3325 + closeSidebar: { 3326 + padding: 8, 3327 + }, 3328 + sidebarTitle: { 3329 + fontSize: 24, 3330 + fontFamily: 'Lexend_700Bold', 3331 + color: darkTheme.colors.text.primary, 3332 + }, 3333 + menuItems: { 3334 + paddingTop: 8, 3335 + }, 3336 + menuItem: { 3337 + flexDirection: 'row', 3338 + alignItems: 'center', 3339 + paddingHorizontal: 20, 3340 + paddingVertical: 16, 3341 + borderBottomWidth: StyleSheet.hairlineWidth, 3342 + borderBottomColor: darkTheme.colors.border.primary, 3343 + }, 3344 + menuItemText: { 3345 + fontSize: 16, 3346 + fontFamily: 'Lexend_400Regular', 3347 + color: darkTheme.colors.text.primary, 3348 + marginLeft: 16, 3349 + }, 3350 + memorySection: { 3351 + flex: 1, 3352 + paddingHorizontal: 16, 3353 + paddingTop: 16, 3354 + }, 3355 + memorySectionTitle: { 3356 + fontSize: 14, 3357 + fontFamily: 'Lexend_600SemiBold', 3358 + color: darkTheme.colors.text.secondary, 3359 + marginBottom: 12, 3360 + textTransform: 'uppercase', 3361 + letterSpacing: 0.5, 3362 + }, 3363 + memoryBlockItem: { 3364 + padding: 16, 3365 + backgroundColor: darkTheme.colors.background.secondary, 3366 + borderRadius: 8, 3367 + marginBottom: 12, 3368 + borderWidth: 1, 3369 + borderColor: darkTheme.colors.border.primary, 3370 + }, 3371 + memoryBlockLabel: { 3372 + fontSize: 16, 3373 + fontFamily: 'Lexend_600SemiBold', 3374 + color: darkTheme.colors.text.primary, 3375 + marginBottom: 4, 3376 + }, 3377 + memoryBlockPreview: { 3378 + fontSize: 14, 3379 + fontFamily: 'Lexend_400Regular', 3380 + color: darkTheme.colors.text.secondary, 3381 + }, 3382 + errorText: { 3383 + color: darkTheme.colors.status.error, 3384 + fontSize: 14, 3385 + fontFamily: 'Lexend_400Regular', 3386 + textAlign: 'center', 3387 + }, 3388 + approvalOverlay: { 3389 + flex: 1, 3390 + backgroundColor: 'rgba(0, 0, 0, 0.7)', 3391 + justifyContent: 'center', 3392 + alignItems: 'center', 3393 + padding: 20, 3394 + }, 3395 + approvalContainer: { 3396 + width: '100%', 3397 + maxWidth: 400, 3398 + backgroundColor: darkTheme.colors.background.primary, 3399 + borderRadius: 16, 3400 + padding: 20, 3401 + }, 3402 + approvalTitle: { 3403 + fontSize: 20, 3404 + fontFamily: 'Lexend_700Bold', 3405 + color: darkTheme.colors.text.primary, 3406 + marginBottom: 16, 3407 + }, 3408 + approvalTool: { 3409 + fontSize: 16, 3410 + fontFamily: 'Lexend_400Regular', 3411 + color: darkTheme.colors.text.primary, 3412 + marginBottom: 12, 3413 + }, 3414 + approvalReasoning: { 3415 + backgroundColor: darkTheme.colors.background.secondary, 3416 + padding: 12, 3417 + borderRadius: 8, 3418 + marginBottom: 16, 3419 + }, 3420 + approvalReasoningLabel: { 3421 + fontSize: 12, 3422 + fontFamily: 'Lexend_600SemiBold', 3423 + color: darkTheme.colors.text.secondary, 3424 + marginBottom: 4, 3425 + }, 3426 + approvalReasoningText: { 3427 + fontSize: 14, 3428 + fontFamily: 'Lexend_400Regular', 3429 + color: darkTheme.colors.text.primary, 3430 + }, 3431 + approvalInput: { 3432 + height: 80, 3433 + borderWidth: 1, 3434 + borderColor: darkTheme.colors.border.primary, 3435 + borderRadius: 8, 3436 + padding: 12, 3437 + fontFamily: 'Lexend_400Regular', 3438 + color: darkTheme.colors.text.primary, 3439 + backgroundColor: darkTheme.colors.background.secondary, 3440 + marginBottom: 16, 3441 + textAlignVertical: 'top', 3442 + }, 3443 + approvalButtons: { 3444 + flexDirection: 'row', 3445 + justifyContent: 'space-between', 3446 + }, 3447 + approvalButton: { 3448 + flex: 1, 3449 + height: 48, 3450 + borderRadius: 8, 3451 + justifyContent: 'center', 3452 + alignItems: 'center', 3453 + marginHorizontal: 4, 3454 + }, 3455 + denyButton: { 3456 + backgroundColor: darkTheme.colors.status.error, 3457 + }, 3458 + approveButton: { 3459 + backgroundColor: darkTheme.colors.status.success, 3460 + }, 3461 + approvalButtonText: { 3462 + color: darkTheme.colors.background.primary, 3463 + fontSize: 16, 3464 + fontFamily: 'Lexend_600SemiBold', 3465 + }, 3466 + typingCursor: { 3467 + width: 2, 3468 + height: 20, 3469 + backgroundColor: darkTheme.colors.interactive.primary, 3470 + marginLeft: 2, 3471 + marginTop: 2, 3472 + }, 3473 + compactionContainer: { 3474 + marginVertical: 16, 3475 + marginHorizontal: 18, 3476 + }, 3477 + compactionLine: { 3478 + flexDirection: 'row', 3479 + alignItems: 'center', 3480 + paddingVertical: 8, 3481 + paddingHorizontal: 12, 3482 + }, 3483 + compactionDivider: { 3484 + flex: 1, 3485 + height: 1, 3486 + backgroundColor: '#3a3a3a', 3487 + }, 3488 + compactionLabel: { 3489 + fontSize: 11, 3490 + fontFamily: 'Lexend_400Regular', 3491 + color: darkTheme.colors.text.tertiary, 3492 + marginHorizontal: 12, 3493 + textTransform: 'lowercase', 3494 + }, 3495 + compactionChevron: { 3496 + marginLeft: 4, 3497 + }, 3498 + compactionMessageContainer: { 3499 + marginTop: 8, 3500 + padding: 12, 3501 + backgroundColor: darkTheme.colors.background.surface, 3502 + borderRadius: 8, 3503 + borderWidth: StyleSheet.hairlineWidth, 3504 + borderColor: darkTheme.colors.border.secondary, 3505 + }, 3506 + compactionMessageText: { 3507 + fontSize: 13, 3508 + fontFamily: 'Lexend_400Regular', 3509 + color: darkTheme.colors.text.secondary, 3510 + lineHeight: 18, 3511 + }, 3512 + // Memory View Styles 3513 + memoryViewContainer: { 3514 + flex: 1, 3515 + backgroundColor: darkTheme.colors.background.primary, 3516 + }, 3517 + knowledgeTabs: { 3518 + flexDirection: 'row', 3519 + paddingHorizontal: 16, 3520 + borderBottomWidth: 1, 3521 + }, 3522 + knowledgeTab: { 3523 + paddingVertical: 12, 3524 + paddingHorizontal: 16, 3525 + marginHorizontal: 4, 3526 + }, 3527 + knowledgeTabText: { 3528 + fontSize: 14, 3529 + fontFamily: 'Lexend_500Medium', 3530 + }, 3531 + memoryViewHeader: { 3532 + paddingHorizontal: 24, 3533 + paddingVertical: 20, 3534 + borderBottomWidth: 1, 3535 + borderBottomColor: darkTheme.colors.border.primary, 3536 + }, 3537 + backToChat: { 3538 + flexDirection: 'row', 3539 + alignItems: 'center', 3540 + marginBottom: 12, 3541 + }, 3542 + backToChatText: { 3543 + fontSize: 14, 3544 + fontFamily: 'Lexend_400Regular', 3545 + marginLeft: 8, 3546 + }, 3547 + memoryViewTitle: { 3548 + fontSize: 32, 3549 + fontFamily: 'Lexend_700Bold', 3550 + }, 3551 + memorySearchContainer: { 3552 + paddingHorizontal: 24, 3553 + paddingVertical: 16, 3554 + flexDirection: 'row', 3555 + alignItems: 'center', 3556 + }, 3557 + memorySearchIcon: { 3558 + position: 'absolute', 3559 + left: 36, 3560 + zIndex: 1, 3561 + }, 3562 + memorySearchInput: { 3563 + flex: 1, 3564 + height: 44, 3565 + paddingLeft: 40, 3566 + paddingRight: 16, 3567 + borderRadius: 22, 3568 + fontSize: 16, 3569 + fontFamily: 'Lexend_400Regular', 3570 + borderWidth: 1, 3571 + }, 3572 + memoryBlocksGrid: { 3573 + flex: 1, 3574 + paddingHorizontal: 16, 3575 + }, 3576 + memoryBlocksContent: { 3577 + paddingBottom: 24, 3578 + }, 3579 + memoryLoadingContainer: { 3580 + flex: 1, 3581 + justifyContent: 'center', 3582 + alignItems: 'center', 3583 + paddingTop: 60, 3584 + }, 3585 + memoryBlockCard: { 3586 + flex: 1, 3587 + margin: 8, 3588 + padding: 20, 3589 + borderRadius: 12, 3590 + borderWidth: 1, 3591 + minHeight: 160, 3592 + maxWidth: '100%', 3593 + }, 3594 + memoryBlockCardHeader: { 3595 + flexDirection: 'row', 3596 + justifyContent: 'space-between', 3597 + alignItems: 'flex-start', 3598 + marginBottom: 12, 3599 + }, 3600 + memoryBlockCardLabel: { 3601 + fontSize: 18, 3602 + fontFamily: 'Lexend_600SemiBold', 3603 + flex: 1, 3604 + }, 3605 + memoryBlockCardCount: { 3606 + fontSize: 12, 3607 + fontFamily: 'Lexend_400Regular', 3608 + marginLeft: 8, 3609 + }, 3610 + memoryBlockCardPreview: { 3611 + fontSize: 14, 3612 + fontFamily: 'Lexend_400Regular', 3613 + lineHeight: 20, 3614 + }, 3615 + memoryEmptyState: { 3616 + flex: 1, 3617 + justifyContent: 'center', 3618 + alignItems: 'center', 3619 + paddingTop: 80, 3620 + }, 3621 + memoryEmptyText: { 3622 + fontSize: 16, 3623 + fontFamily: 'Lexend_400Regular', 3624 + marginTop: 16, 3625 + }, 3626 + assistantMessageWithCopyContainer: { 3627 + position: 'relative', 3628 + flex: 1, 3629 + }, 3630 + copyButtonContainer: { 3631 + position: 'absolute', 3632 + top: 0, 3633 + right: 0, 3634 + zIndex: 10, 3635 + }, 3636 + copyButton: { 3637 + padding: 8, 3638 + opacity: 0.3, 3639 + borderRadius: 4, 3640 + }, 3641 + reasoningStreamingContainer: { 3642 + paddingVertical: 12, 3643 + paddingHorizontal: 16, 3644 + paddingLeft: 20, 3645 + marginBottom: 12, 3646 + backgroundColor: 'rgba(255, 255, 255, 0.04)', 3647 + borderRadius: 8, 3648 + borderLeftWidth: 4, 3649 + borderLeftColor: '#555555', 3650 + overflow: 'hidden', 3651 + }, 3652 + reasoningStreamingText: { 3653 + fontSize: 14, 3654 + fontFamily: 'Lexend_400Regular', 3655 + color: darkTheme.colors.text.secondary, 3656 + lineHeight: 22, 3657 + fontStyle: 'normal', 3658 + }, 3659 + toolCallStreamingContainer: { 3660 + paddingLeft: 20, 3661 + paddingRight: 16, 3662 + marginBottom: 12, 3663 + }, 3664 + 3665 + // Modal styles 3666 + modalOverlay: { 3667 + position: 'absolute', 3668 + top: 0, 3669 + left: 0, 3670 + right: 0, 3671 + bottom: 0, 3672 + backgroundColor: 'rgba(0, 0, 0, 0.7)', 3673 + justifyContent: 'center', 3674 + alignItems: 'center', 3675 + zIndex: 2000, 3676 + }, 3677 + modalContent: { 3678 + width: '90%', 3679 + maxWidth: 600, 3680 + borderRadius: 16, 3681 + borderWidth: 1, 3682 + padding: 24, 3683 + maxHeight: '80%', 3684 + }, 3685 + modalHeader: { 3686 + flexDirection: 'row', 3687 + justifyContent: 'space-between', 3688 + alignItems: 'center', 3689 + marginBottom: 20, 3690 + }, 3691 + modalTitle: { 3692 + fontSize: 24, 3693 + fontFamily: 'Lexend_600SemiBold', 3694 + }, 3695 + modalBody: { 3696 + marginBottom: 20, 3697 + }, 3698 + inputLabel: { 3699 + fontSize: 14, 3700 + fontFamily: 'Lexend_500Medium', 3701 + marginBottom: 8, 3702 + }, 3703 + textInput: { 3704 + height: 44, 3705 + borderRadius: 8, 3706 + borderWidth: 1, 3707 + paddingHorizontal: 12, 3708 + fontSize: 16, 3709 + fontFamily: 'Lexend_400Regular', 3710 + }, 3711 + textArea: { 3712 + minHeight: 120, 3713 + borderRadius: 8, 3714 + borderWidth: 1, 3715 + paddingHorizontal: 12, 3716 + paddingVertical: 12, 3717 + fontSize: 16, 3718 + fontFamily: 'Lexend_400Regular', 3719 + textAlignVertical: 'top', 3720 + }, 3721 + modalFooter: { 3722 + flexDirection: 'row', 3723 + justifyContent: 'flex-end', 3724 + gap: 12, 3725 + }, 3726 + modalButton: { 3727 + paddingVertical: 12, 3728 + paddingHorizontal: 24, 3729 + borderRadius: 8, 3730 + minWidth: 100, 3731 + alignItems: 'center', 3732 + }, 3733 + modalButtonSecondary: { 3734 + borderWidth: 1, 3735 + }, 3736 + modalButtonPrimary: { 3737 + // Background color set dynamically 3738 + }, 3739 + modalButtonText: { 3740 + fontSize: 16, 3741 + fontFamily: 'Lexend_500Medium', 3742 + }, 3743 + // Settings styles 3744 + settingsHeader: { 3745 + paddingVertical: 16, 3746 + paddingHorizontal: 20, 3747 + borderBottomWidth: 1, 3748 + }, 3749 + settingsTitle: { 3750 + fontSize: 24, 3751 + fontFamily: 'Lexend_600SemiBold', 3752 + }, 3753 + settingsContent: { 3754 + flex: 1, 3755 + }, 3756 + settingItem: { 3757 + flexDirection: 'row', 3758 + justifyContent: 'space-between', 3759 + alignItems: 'center', 3760 + paddingVertical: 16, 3761 + paddingHorizontal: 20, 3762 + borderBottomWidth: 1, 3763 + }, 3764 + settingInfo: { 3765 + flex: 1, 3766 + marginRight: 16, 3767 + }, 3768 + settingLabel: { 3769 + fontSize: 16, 3770 + fontFamily: 'Lexend_500Medium', 3771 + marginBottom: 4, 3772 + }, 3773 + settingDescription: { 3774 + fontSize: 14, 3775 + fontFamily: 'Lexend_400Regular', 3776 + lineHeight: 20, 3777 + }, 3778 + toggle: { 3779 + width: 50, 3780 + height: 30, 3781 + borderRadius: 15, 3782 + backgroundColor: '#666', 3783 + justifyContent: 'center', 3784 + padding: 2, 3785 + }, 3786 + toggleActive: { 3787 + backgroundColor: '#4D96FF', 3788 + }, 3789 + toggleThumb: { 3790 + width: 26, 3791 + height: 26, 3792 + borderRadius: 13, 3793 + backgroundColor: '#fff', 3794 + }, 3795 + toggleThumbActive: { 3796 + alignSelf: 'flex-end', 3797 + }, 3798 + toolReturnContainer: { 3799 + width: '100%', 3800 + marginTop: -8, 3801 + marginBottom: 4, 3802 + }, 3803 + toolReturnHeader: { 3804 + flexDirection: 'row', 3805 + alignItems: 'center', 3806 + gap: 3, 3807 + paddingVertical: 4, 3808 + paddingHorizontal: 8, 3809 + backgroundColor: 'transparent', 3810 + borderRadius: 4, 3811 + }, 3812 + toolReturnLabel: { 3813 + fontSize: 10, 3814 + fontFamily: 'Lexend_400Regular', 3815 + color: darkTheme.colors.text.tertiary, 3816 + opacity: 0.5, 3817 + }, 3818 + toolReturnContent: { 3819 + backgroundColor: 'rgba(30, 30, 30, 0.3)', 3820 + borderRadius: 4, 3821 + padding: 8, 3822 + borderWidth: 1, 3823 + borderColor: 'rgba(255, 255, 255, 0.03)', 3824 + marginTop: 0, 104 3825 }, 105 3826 });
+276
MIGRATION_TRACKER.md
··· 1 + # Migration Tracker - Incremental Refactor 2 + 3 + ## Overview 4 + This document tracks the extraction of components from `App.tsx.monolithic` (3,826 lines) into modular, reusable components. 5 + 6 + **Strategy**: Zero-risk incremental refactor 7 + - Extract components WITHOUT breaking the working app 8 + - Build new components alongside old code 9 + - Test with `App.new.tsx` before final migration 10 + - Never lose features 11 + 12 + ## Component Status 13 + 14 + ### ✅ Completed Components 15 + 16 + #### 1. AppHeader 17 + - **File**: `src/components/AppHeader.tsx` 18 + - **Replaces**: `App.tsx.monolithic` lines 2083-2124 19 + - **Features**: 20 + - Menu button for sidebar 21 + - "co" title with developer mode easter egg (7 taps) 22 + - Safe area inset support 23 + - Conditional rendering (hides when no messages) 24 + - **Status**: ✅ Extracted, tested, ready 25 + - **Used by**: None yet (pending App.new.tsx) 26 + 27 + #### 2. BottomNavigation 28 + - **File**: `src/components/BottomNavigation.tsx` 29 + - **Replaces**: `App.tsx.monolithic` lines 2126-2172 30 + - **Features**: 31 + - 4 tabs: You, Chat, Knowledge, Settings 32 + - Active state highlighting 33 + - Callbacks for view switching 34 + - Conditional rendering (hides when no messages) 35 + - **Status**: ✅ Extracted, tested, ready 36 + - **Used by**: None yet (pending App.new.tsx) 37 + 38 + ### 🚧 In Progress 39 + 40 + None currently 41 + 42 + ### 📋 Pending Extraction 43 + 44 + #### 3. AppSidebar 45 + - **Will replace**: `App.tsx.monolithic` lines 1924-2081 46 + - **Features needed**: 47 + - Animated slide-in drawer 48 + - File attachment list 49 + - Knowledge management links 50 + - Settings access 51 + - Logout option 52 + - **Complexity**: Medium 53 + - **Dependencies**: React Native Animated API 54 + 55 + #### 4. YouView (Memory Blocks) 56 + - **Will replace**: `App.tsx.monolithic` lines 2182-2432 57 + - **Features needed**: 58 + - Memory block viewer 59 + - "You" block creation/editing 60 + - Human/persona blocks display 61 + - Loading states 62 + - Empty states 63 + - **Complexity**: High 64 + - **Dependencies**: lettaApi, MemoryBlockViewer component 65 + 66 + #### 5. KnowledgeView 67 + - **Will replace**: `App.tsx.monolithic` lines 2434-2756 68 + - **Features needed**: 69 + - Tab switcher (Files / Archival Memory) 70 + - File upload/delete 71 + - Archival memory search 72 + - Passage creation/deletion 73 + - Search functionality 74 + - **Complexity**: High 75 + - **Dependencies**: lettaApi, file picker 76 + 77 + #### 6. SettingsView 78 + - **Will replace**: `App.tsx.monolithic` lines 2758-2850 (approx) 79 + - **Features needed**: 80 + - Logout button 81 + - Developer mode toggle 82 + - Agent configuration 83 + - App version/info 84 + - **Complexity**: Low 85 + - **Dependencies**: Storage, lettaApi 86 + 87 + #### 7. ChatView (Enhanced) 88 + - **Current**: `src/screens/ChatScreen.tsx` (basic) 89 + - **Needs**: Integration with refactored app structure 90 + - **Features to add**: 91 + - Approval request handling 92 + - Developer mode code blocks 93 + - Copy message functionality 94 + - Message retry 95 + - **Complexity**: Medium 96 + - **Status**: Partially complete 97 + 98 + ## Architecture Components 99 + 100 + ### Already Created (From Previous Refactor) 101 + 102 + ✅ **State Management** 103 + - `src/stores/authStore.ts` - Authentication 104 + - `src/stores/agentStore.ts` - Co agent lifecycle 105 + - `src/stores/chatStore.ts` - Messages and streaming 106 + 107 + ✅ **Hooks** 108 + - `src/hooks/useAuth.ts` - Auth flow 109 + - `src/hooks/useAgent.ts` - Agent initialization 110 + - `src/hooks/useMessages.ts` - Message loading 111 + - `src/hooks/useMessageStream.ts` - Streaming 112 + - `src/hooks/useErrorHandler.ts` - Error handling 113 + 114 + ✅ **Components** 115 + - `src/components/ErrorBoundary.tsx` - Error boundaries 116 + - `src/components/MessageBubble.v2.tsx` - Message bubbles 117 + - `src/components/MessageInput.v2.tsx` - Input field 118 + - `src/components/LogoLoader.tsx` - Loading screen 119 + 120 + ✅ **Infrastructure** 121 + - `src/config/index.ts` - App configuration 122 + - `src/theme/` - Theme system 123 + 124 + ## Migration Plan 125 + 126 + ### Phase 1: UI Chrome (Current) 127 + - [x] Extract AppHeader 128 + - [x] Extract BottomNavigation 129 + - [ ] Extract AppSidebar 130 + - [ ] Create MIGRATION_TRACKER.md 131 + 132 + ### Phase 2: Views 133 + - [ ] Extract YouView 134 + - [ ] Extract KnowledgeView 135 + - [ ] Extract SettingsView 136 + - [ ] Enhance ChatView 137 + 138 + ### Phase 3: Integration 139 + - [ ] Create App.new.tsx 140 + - [ ] Add toggle flag in index.ts 141 + - [ ] Wire up all components 142 + - [ ] Test feature parity 143 + 144 + ### Phase 4: Testing 145 + - [ ] Test all views 146 + - [ ] Test navigation 147 + - [ ] Test data flow 148 + - [ ] Test edge cases 149 + - [ ] Compare with monolithic version 150 + 151 + ### Phase 5: Final Migration 152 + - [ ] Achieve 100% feature parity 153 + - [ ] User acceptance testing 154 + - [ ] Replace App.tsx 155 + - [ ] Delete App.tsx.monolithic 156 + - [ ] Update documentation 157 + 158 + ## Feature Checklist 159 + 160 + Features that MUST work in the new app: 161 + 162 + ### Authentication 163 + - [x] Login with API token 164 + - [x] Token persistence 165 + - [x] Logout 166 + - [x] Connection status 167 + 168 + ### Chat 169 + - [x] Send messages 170 + - [x] Receive responses 171 + - [x] Streaming responses 172 + - [ ] Message retry 173 + - [ ] Copy messages 174 + - [ ] Approval requests 175 + - [ ] Developer mode code blocks 176 + 177 + ### Memory (You View) 178 + - [ ] View memory blocks 179 + - [ ] Edit "You" block 180 + - [ ] View human/persona 181 + - [ ] Create new memory blocks 182 + 183 + ### Knowledge 184 + - [ ] Upload files 185 + - [ ] Delete files 186 + - [ ] Search archival memory 187 + - [ ] Create passages 188 + - [ ] Delete passages 189 + - [ ] View file metadata 190 + 191 + ### Settings 192 + - [ ] Logout 193 + - [ ] Toggle developer mode 194 + - [ ] View agent info 195 + 196 + ### Navigation 197 + - [ ] Header with menu 198 + - [ ] Bottom nav tabs 199 + - [ ] Sidebar drawer 200 + - [ ] View switching 201 + - [ ] Empty state handling 202 + 203 + ## Testing Strategy 204 + 205 + ### Component Testing (Per Component) 206 + 1. Visual inspection 207 + 2. Props validation 208 + 3. Callback testing 209 + 4. Theme switching 210 + 5. Edge cases 211 + 212 + ### Integration Testing (App.new.tsx) 213 + 1. Navigation flow 214 + 2. State persistence 215 + 3. API calls 216 + 4. Error handling 217 + 5. Loading states 218 + 219 + ### Comparison Testing 220 + 1. Side-by-side with monolithic 221 + 2. Feature checklist validation 222 + 3. Performance comparison 223 + 4. Memory usage 224 + 5. Bundle size 225 + 226 + ## Risk Mitigation 227 + 228 + **What if we find missing features?** 229 + - Keep App.tsx.monolithic as reference 230 + - Add features to extracted components 231 + - Update this tracker 232 + - Re-test 233 + 234 + **What if integration breaks?** 235 + - Revert to App.tsx.monolithic 236 + - Debug in App.new.tsx 237 + - Fix issues before migrating 238 + 239 + **What if we can't achieve parity?** 240 + - Document differences 241 + - Decide: fix or accept 242 + - Get user approval 243 + - Proceed cautiously 244 + 245 + ## Success Criteria 246 + 247 + The new app is ready when: 248 + - ✅ All components extracted 249 + - ✅ App.new.tsx fully functional 250 + - ✅ 100% feature parity verified 251 + - ✅ No regressions found 252 + - ✅ Performance equal or better 253 + - ✅ User acceptance obtained 254 + 255 + ## Notes 256 + 257 + - Each component has inline documentation 258 + - Line numbers reference App.tsx.monolithic 259 + - Use checkboxes to track progress 260 + - Update this file as we extract 261 + - Keep old code until migration complete 262 + 263 + ## Quick Reference 264 + 265 + **Current Files**: 266 + - `App.tsx.monolithic` - Original working version (backup) 267 + - `App.tsx` - Currently points to monolithic 268 + - `App.new.tsx` - New modular version (to be created) 269 + 270 + **Toggle Testing**: 271 + ```typescript 272 + // In index.ts (future) 273 + const USE_NEW_APP = false; // Set to true to test 274 + ``` 275 + 276 + Last Updated: {{DATE}}
+142
src/components/AppHeader.tsx
··· 1 + /** 2 + * AppHeader Component 3 + * 4 + * MIGRATION STATUS: ✅ EXTRACTED - Ready for use 5 + * 6 + * REPLACES: App.tsx.monolithic lines 2083-2124 7 + * - Header bar with menu button 8 + * - App title ("co") with developer mode activation (7 clicks) 9 + * - Conditional rendering based on message count 10 + * 11 + * FEATURES: 12 + * - Menu button to toggle sidebar 13 + * - Title with hidden developer mode toggle (tap 7 times in 2 seconds) 14 + * - Responsive to safe area insets 15 + * - Theme-aware styling 16 + * - Hides when no messages (empty state) 17 + * 18 + * DEPENDENCIES: 19 + * - Ionicons 20 + * - react-native-safe-area-context 21 + * - Theme system 22 + * 23 + * USED BY: (not yet integrated) 24 + * - [ ] App.new.tsx (planned) 25 + * 26 + * RELATED COMPONENTS: 27 + * - AppSidebar.tsx (triggered by menu button) 28 + * - BottomNavigation.tsx (view switcher below this) 29 + */ 30 + 31 + import React, { useRef, useState } from 'react'; 32 + import { View, Text, TouchableOpacity, StyleSheet, Platform, Alert } from 'react-native'; 33 + import { Ionicons } from '@expo/vector-icons'; 34 + import { useSafeAreaInsets } from 'react-native-safe-area-context'; 35 + import type { Theme } from '../theme'; 36 + 37 + interface AppHeaderProps { 38 + theme: Theme; 39 + colorScheme: 'light' | 'dark'; 40 + hasMessages: boolean; 41 + onMenuPress: () => void; 42 + developerMode: boolean; 43 + onDeveloperModeToggle: () => void; 44 + } 45 + 46 + export function AppHeader({ 47 + theme, 48 + colorScheme, 49 + hasMessages, 50 + onMenuPress, 51 + developerMode, 52 + onDeveloperModeToggle, 53 + }: AppHeaderProps) { 54 + const insets = useSafeAreaInsets(); 55 + const [headerClickCount, setHeaderClickCount] = useState(0); 56 + const headerClickTimeoutRef = useRef<NodeJS.Timeout | null>(null); 57 + 58 + const handleTitlePress = () => { 59 + setHeaderClickCount(prev => prev + 1); 60 + 61 + if (headerClickTimeoutRef.current) { 62 + clearTimeout(headerClickTimeoutRef.current); 63 + } 64 + 65 + headerClickTimeoutRef.current = setTimeout(() => { 66 + if (headerClickCount >= 6) { 67 + onDeveloperModeToggle(); 68 + if (Platform.OS === 'web') { 69 + window.alert(developerMode ? 'Developer mode disabled' : 'Developer mode enabled'); 70 + } else { 71 + Alert.alert('Developer Mode', developerMode ? 'Disabled' : 'Enabled'); 72 + } 73 + } 74 + setHeaderClickCount(0); 75 + }, 2000); 76 + }; 77 + 78 + return ( 79 + <View 80 + style={[ 81 + styles.header, 82 + { 83 + paddingTop: insets.top + 12, 84 + backgroundColor: theme.colors.background.secondary, 85 + borderBottomColor: theme.colors.border.primary, 86 + }, 87 + !hasMessages && { 88 + backgroundColor: 'transparent', 89 + borderBottomWidth: 0, 90 + }, 91 + ]} 92 + > 93 + <TouchableOpacity onPress={onMenuPress} style={styles.menuButton}> 94 + <Ionicons 95 + name="menu" 96 + size={24} 97 + color={colorScheme === 'dark' ? '#FFFFFF' : theme.colors.text.primary} 98 + /> 99 + </TouchableOpacity> 100 + 101 + {hasMessages && ( 102 + <> 103 + <View style={styles.headerCenter}> 104 + <TouchableOpacity onPress={handleTitlePress}> 105 + <Text style={[styles.headerTitle, { color: theme.colors.text.primary }]}> 106 + co 107 + </Text> 108 + </TouchableOpacity> 109 + </View> 110 + 111 + <View style={styles.headerSpacer} /> 112 + </> 113 + )} 114 + </View> 115 + ); 116 + } 117 + 118 + const styles = StyleSheet.create({ 119 + header: { 120 + flexDirection: 'row', 121 + alignItems: 'center', 122 + paddingHorizontal: 16, 123 + paddingBottom: 12, 124 + borderBottomWidth: 1, 125 + }, 126 + menuButton: { 127 + padding: 8, 128 + }, 129 + headerCenter: { 130 + flex: 1, 131 + alignItems: 'center', 132 + }, 133 + headerTitle: { 134 + fontSize: 36, 135 + fontFamily: 'Lexend_700Bold', 136 + }, 137 + headerSpacer: { 138 + width: 40, // Balance the menu button width 139 + }, 140 + }); 141 + 142 + export default AppHeader;
+200
src/components/BottomNavigation.tsx
··· 1 + /** 2 + * BottomNavigation Component 3 + * 4 + * MIGRATION STATUS: ✅ EXTRACTED - Ready for use 5 + * 6 + * REPLACES: App.tsx.monolithic lines 2126-2176 7 + * - View switcher tabs (You, Chat, Knowledge, Settings) 8 + * - Active state styling 9 + * - Conditional rendering based on message count 10 + * 11 + * FEATURES: 12 + * - 4 navigation tabs: You, Chat, Knowledge, Settings 13 + * - Active tab highlighting 14 + * - Triggers callbacks for view switching 15 + * - Theme-aware colors 16 + * - Hides when no messages (empty state) 17 + * 18 + * CALLBACKS: 19 + * - onYouPress: Load memory blocks 20 + * - onChatPress: Switch to chat view 21 + * - onKnowledgePress: Load knowledge/archival memory 22 + * - onSettingsPress: Open settings 23 + * 24 + * DEPENDENCIES: 25 + * - Theme system 26 + * - Lexend fonts 27 + * 28 + * USED BY: (not yet integrated) 29 + * - [ ] App.new.tsx (planned) 30 + * 31 + * RELATED COMPONENTS: 32 + * - AppHeader.tsx (appears above this) 33 + * - YouView.tsx (shown when "You" is active) 34 + * - ChatScreen.tsx (shown when "Chat" is active) 35 + * - KnowledgeView.tsx (shown when "Knowledge" is active) 36 + * - SettingsView.tsx (shown when "Settings" is active) 37 + */ 38 + 39 + import React from 'react'; 40 + import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; 41 + import type { Theme } from '../theme'; 42 + 43 + export type ViewType = 'you' | 'chat' | 'knowledge' | 'settings'; 44 + 45 + interface BottomNavigationProps { 46 + theme: Theme; 47 + currentView: ViewType; 48 + hasMessages: boolean; 49 + onYouPress: () => void; 50 + onChatPress: () => void; 51 + onKnowledgePress: () => void; 52 + onSettingsPress: () => void; 53 + } 54 + 55 + export function BottomNavigation({ 56 + theme, 57 + currentView, 58 + hasMessages, 59 + onYouPress, 60 + onChatPress, 61 + onKnowledgePress, 62 + onSettingsPress, 63 + }: BottomNavigationProps) { 64 + // Hide when chat is empty 65 + if (!hasMessages) { 66 + return null; 67 + } 68 + 69 + return ( 70 + <View 71 + style={[ 72 + styles.viewSwitcher, 73 + { backgroundColor: theme.colors.background.secondary }, 74 + ]} 75 + > 76 + <TouchableOpacity 77 + style={[ 78 + styles.viewSwitcherButton, 79 + currentView === 'you' && { 80 + backgroundColor: theme.colors.background.tertiary, 81 + }, 82 + ]} 83 + onPress={onYouPress} 84 + > 85 + <Text 86 + style={[ 87 + styles.viewSwitcherText, 88 + { 89 + color: 90 + currentView === 'you' 91 + ? theme.colors.text.primary 92 + : theme.colors.text.tertiary, 93 + }, 94 + ]} 95 + > 96 + You 97 + </Text> 98 + </TouchableOpacity> 99 + 100 + <TouchableOpacity 101 + style={[ 102 + styles.viewSwitcherButton, 103 + currentView === 'chat' && { 104 + backgroundColor: theme.colors.background.tertiary, 105 + }, 106 + ]} 107 + onPress={onChatPress} 108 + > 109 + <Text 110 + style={[ 111 + styles.viewSwitcherText, 112 + { 113 + color: 114 + currentView === 'chat' 115 + ? theme.colors.text.primary 116 + : theme.colors.text.tertiary, 117 + }, 118 + ]} 119 + > 120 + Chat 121 + </Text> 122 + </TouchableOpacity> 123 + 124 + <TouchableOpacity 125 + style={[ 126 + styles.viewSwitcherButton, 127 + currentView === 'knowledge' && { 128 + backgroundColor: theme.colors.background.tertiary, 129 + }, 130 + ]} 131 + onPress={onKnowledgePress} 132 + > 133 + <Text 134 + style={[ 135 + styles.viewSwitcherText, 136 + { 137 + color: 138 + currentView === 'knowledge' 139 + ? theme.colors.text.primary 140 + : theme.colors.text.tertiary, 141 + }, 142 + ]} 143 + > 144 + Knowledge 145 + </Text> 146 + </TouchableOpacity> 147 + 148 + <TouchableOpacity 149 + style={[ 150 + styles.viewSwitcherButton, 151 + currentView === 'settings' && { 152 + backgroundColor: theme.colors.background.tertiary, 153 + }, 154 + ]} 155 + onPress={onSettingsPress} 156 + > 157 + <Text 158 + style={[ 159 + styles.viewSwitcherText, 160 + { 161 + color: 162 + currentView === 'settings' 163 + ? theme.colors.text.primary 164 + : theme.colors.text.tertiary, 165 + }, 166 + ]} 167 + > 168 + Settings 169 + </Text> 170 + </TouchableOpacity> 171 + </View> 172 + ); 173 + } 174 + 175 + const styles = StyleSheet.create({ 176 + viewSwitcher: { 177 + flexDirection: 'row', 178 + justifyContent: 'space-between', 179 + alignItems: 'center', 180 + paddingHorizontal: 16, 181 + paddingVertical: 4, 182 + maxWidth: 400, 183 + alignSelf: 'center', 184 + width: '100%', 185 + }, 186 + viewSwitcherButton: { 187 + paddingVertical: 6, 188 + paddingHorizontal: 16, 189 + borderRadius: 8, 190 + flex: 1, 191 + alignItems: 'center', 192 + justifyContent: 'center', 193 + }, 194 + viewSwitcherText: { 195 + fontSize: 14, 196 + fontFamily: 'Lexend_500Medium', 197 + }, 198 + }); 199 + 200 + export default BottomNavigation;