A React Native app for the ultimate thinking partner.

feat(ui): implement responsive sidebar with push/overlay modes

- Add screen width detection using useWindowDimensions
- Wide screens (≥768px): sidebar pushes content with animated width
- Narrow screens (<768px): sidebar overlays content with backdrop
- Improved animation timing (200ms) with fade effects
- Auto-close sidebar on mobile after menu selection
- Maintain all existing functionality across both modes

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

+275 -42
+244 -38
App.new.tsx
··· 32 32 Animated, 33 33 Alert, 34 34 Linking, 35 + useWindowDimensions, 35 36 } from 'react-native'; 36 37 import { SafeAreaProvider } from 'react-native-safe-area-context'; 37 38 import * as SystemUI from 'expo-system-ui'; ··· 63 64 64 65 // API and Utils 65 66 import lettaApi from './src/api/lettaApi'; 67 + import { Storage, STORAGE_KEYS } from './src/utils/storage'; 68 + import { pickFile } from './src/utils/fileUpload'; 66 69 import type { MemoryBlock, Passage } from './src/types/letta'; 67 70 68 71 function CoApp() { 69 72 const systemColorScheme = useSystemColorScheme(); 70 73 const [colorScheme, setColorScheme] = useState<'light' | 'dark'>(systemColorScheme || 'dark'); 74 + const { width: screenWidth } = useWindowDimensions(); 75 + const isNarrowScreen = screenWidth < 768; // iPad portrait width threshold 71 76 72 77 // Load fonts 73 78 const [fontsLoaded] = useFonts({ ··· 139 144 useEffect(() => { 140 145 Animated.timing(sidebarAnimRef, { 141 146 toValue: sidebarVisible ? 1 : 0, 142 - duration: 300, 147 + duration: 200, 143 148 useNativeDriver: false, 144 149 }).start(); 145 150 }, [sidebarVisible]); ··· 215 220 }; 216 221 217 222 const loadFiles = async () => { 218 - // TODO: Implement folder initialization and file loading 219 - // This requires creating/finding a "co-app" folder first 220 - // See App.tsx.monolithic lines 1105-1200 for full implementation 221 - console.log('File loading not yet implemented in refactored version'); 222 - setIsLoadingFiles(false); 223 + if (!coAgent) return; 224 + 225 + setIsLoadingFiles(true); 226 + setFilesError(null); 227 + 228 + try { 229 + // Get cached folder ID or find/create folder 230 + let folderId = await Storage.getItem(STORAGE_KEYS.CO_FOLDER_ID); 231 + 232 + if (!folderId) { 233 + console.log('No folder ID found, searching for co-app folder...'); 234 + const folders = await lettaApi.listFolders({ name: 'co-app' }); 235 + 236 + if (folders.length > 0) { 237 + folderId = folders[0].id; 238 + console.log('Found existing co-app folder:', folderId); 239 + } else { 240 + console.log('Creating new co-app folder...'); 241 + const folder = await lettaApi.createFolder('co-app', 'Files shared with co agent'); 242 + folderId = folder.id; 243 + console.log('Created new folder:', folderId); 244 + } 245 + 246 + await Storage.setItem(STORAGE_KEYS.CO_FOLDER_ID, folderId); 247 + } 248 + 249 + // Attach folder to agent if not already attached 250 + try { 251 + await lettaApi.attachFolderToAgent(coAgent.id, folderId); 252 + console.log('Folder attached to agent'); 253 + } catch (error: any) { 254 + if (error.message?.includes('already attached') || error.status === 409) { 255 + console.log('Folder already attached to agent'); 256 + } else { 257 + throw error; 258 + } 259 + } 260 + 261 + // Load files from folder 262 + console.log('Loading files from folder:', folderId); 263 + const files = await lettaApi.listFolderFiles(folderId); 264 + console.log('Loaded files:', files.length); 265 + setFolderFiles(files); 266 + } catch (error: any) { 267 + console.error('Failed to load files:', error); 268 + setFilesError(error.message || 'Failed to load files'); 269 + } finally { 270 + setIsLoadingFiles(false); 271 + } 223 272 }; 224 273 225 - const handleFileUpload = () => { 226 - // TODO: Implement file upload 227 - console.log('File upload not yet implemented in refactored version'); 274 + const handleFileUpload = async () => { 275 + if (!coAgent) return; 276 + 277 + try { 278 + setIsUploadingFile(true); 279 + setUploadProgress('Selecting file...'); 280 + 281 + const result = await pickFile(); 282 + if (!result) { 283 + setUploadProgress(null); 284 + setIsUploadingFile(false); 285 + return; 286 + } 287 + 288 + setUploadProgress(`Uploading ${result.name}...`); 289 + 290 + // Get cached folder ID 291 + let folderId = await Storage.getItem(STORAGE_KEYS.CO_FOLDER_ID); 292 + 293 + if (!folderId) { 294 + const folders = await lettaApi.listFolders({ name: 'co-app' }); 295 + if (folders.length > 0) { 296 + folderId = folders[0].id; 297 + } else { 298 + const folder = await lettaApi.createFolder('co-app', 'Files shared with co agent'); 299 + folderId = folder.id; 300 + } 301 + await Storage.setItem(STORAGE_KEYS.CO_FOLDER_ID, folderId); 302 + } 303 + 304 + await lettaApi.uploadFileToFolder(folderId, result.file, 'replace'); 305 + 306 + // Attach folder to agent if not already attached 307 + try { 308 + await lettaApi.attachFolderToAgent(coAgent.id, folderId); 309 + } catch (error: any) { 310 + if (!error.message?.includes('already attached') && error.status !== 409) { 311 + throw error; 312 + } 313 + } 314 + 315 + setUploadProgress('Processing...'); 316 + 317 + // Wait a moment for processing then reload files 318 + await new Promise(resolve => setTimeout(resolve, 1000)); 319 + await loadFiles(); 320 + 321 + setUploadProgress(null); 322 + } catch (error: any) { 323 + console.error('File upload error:', error); 324 + setFilesError(error.message || 'Failed to upload file'); 325 + setUploadProgress(null); 326 + } finally { 327 + setIsUploadingFile(false); 328 + } 228 329 }; 229 330 230 331 const handleFileDelete = async (id: string, name: string) => { 231 - // TODO: Implement file deletion 232 - // This requires folder ID (see App.tsx.monolithic line 1359) 233 - console.log('File deletion not yet implemented:', name); 332 + if (!coAgent) return; 333 + 334 + const confirmDelete = Platform.OS === 'web' 335 + ? window.confirm(`Delete file "${name}"?`) 336 + : await new Promise(resolve => { 337 + Alert.alert( 338 + 'Delete File', 339 + `Delete "${name}"?`, 340 + [ 341 + { text: 'Cancel', style: 'cancel', onPress: () => resolve(false) }, 342 + { text: 'Delete', style: 'destructive', onPress: () => resolve(true) }, 343 + ] 344 + ); 345 + }); 346 + 347 + if (!confirmDelete) return; 348 + 349 + try { 350 + const folderId = await Storage.getItem(STORAGE_KEYS.CO_FOLDER_ID); 351 + if (!folderId) { 352 + throw new Error('Folder not found'); 353 + } 354 + 355 + await lettaApi.deleteFile(folderId, id); 356 + await loadFiles(); 357 + } catch (error: any) { 358 + console.error('Failed to delete file:', error); 359 + const msg = error.message || 'Failed to delete file'; 360 + if (Platform.OS === 'web') { 361 + window.alert(msg); 362 + } else { 363 + Alert.alert('Error', msg); 364 + } 365 + } 234 366 }; 235 367 236 368 const handlePassageDelete = async (id: string) => { ··· 323 455 <View style={[styles.container, { backgroundColor: theme.colors.background.primary }]}> 324 456 <StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} /> 325 457 326 - {/* Sidebar */} 327 - <AppSidebar 328 - theme={theme} 329 - colorScheme={colorScheme} 330 - visible={sidebarVisible} 331 - animationValue={sidebarAnimRef} 332 - developerMode={developerMode} 333 - agentId={coAgent.id} 334 - currentView={currentView} 335 - onClose={() => setSidebarVisible(false)} 336 - onYouPress={() => { 337 - setCurrentView('you'); 338 - loadMemoryBlocks(); 339 - }} 340 - onChatPress={() => setCurrentView('chat')} 341 - onKnowledgePress={() => { 342 - setCurrentView('knowledge'); 343 - loadMemoryBlocks(); 344 - }} 345 - onSettingsPress={() => setCurrentView('settings')} 346 - onThemeToggle={toggleColorScheme} 347 - onRefreshAgent={handleRefreshAgent} 348 - onLogout={logout} 349 - /> 458 + <View style={[styles.appLayout, isNarrowScreen && styles.appLayoutNarrow]}> 459 + {/* Sidebar - only show in desktop layout when not narrow */} 460 + {!isNarrowScreen && ( 461 + <AppSidebar 462 + theme={theme} 463 + colorScheme={colorScheme} 464 + visible={sidebarVisible} 465 + animationValue={sidebarAnimRef} 466 + developerMode={developerMode} 467 + agentId={coAgent.id} 468 + currentView={currentView} 469 + isOverlay={false} 470 + onClose={() => setSidebarVisible(false)} 471 + onYouPress={() => { 472 + setCurrentView('you'); 473 + loadMemoryBlocks(); 474 + }} 475 + onChatPress={() => setCurrentView('chat')} 476 + onKnowledgePress={() => { 477 + setCurrentView('knowledge'); 478 + loadMemoryBlocks(); 479 + }} 480 + onSettingsPress={() => setCurrentView('settings')} 481 + onThemeToggle={toggleColorScheme} 482 + onRefreshAgent={handleRefreshAgent} 483 + onLogout={logout} 484 + /> 485 + )} 350 486 351 - {/* Main Content */} 352 - <View style={styles.mainContent}> 487 + {/* Main Content */} 488 + <View style={styles.mainContent}> 353 489 {/* Header */} 354 490 <AppHeader 355 491 theme={theme} ··· 436 572 isDesktop={isDesktop} 437 573 /> 438 574 )} 575 + </View> 576 + 577 + {/* Overlay Sidebar for narrow screens */} 578 + {isNarrowScreen && sidebarVisible && ( 579 + <> 580 + {/* Backdrop */} 581 + <Animated.View 582 + style={[ 583 + styles.backdrop, 584 + { 585 + opacity: sidebarAnimRef.interpolate({ 586 + inputRange: [0, 1], 587 + outputRange: [0, 0.5], 588 + }), 589 + }, 590 + ]} 591 + onTouchEnd={() => setSidebarVisible(false)} 592 + /> 593 + 594 + {/* Overlay Sidebar */} 595 + <AppSidebar 596 + theme={theme} 597 + colorScheme={colorScheme} 598 + visible={sidebarVisible} 599 + animationValue={sidebarAnimRef} 600 + developerMode={developerMode} 601 + agentId={coAgent.id} 602 + currentView={currentView} 603 + isOverlay={true} 604 + onClose={() => setSidebarVisible(false)} 605 + onYouPress={() => { 606 + setCurrentView('you'); 607 + loadMemoryBlocks(); 608 + setSidebarVisible(false); 609 + }} 610 + onChatPress={() => { 611 + setCurrentView('chat'); 612 + setSidebarVisible(false); 613 + }} 614 + onKnowledgePress={() => { 615 + setCurrentView('knowledge'); 616 + loadMemoryBlocks(); 617 + setSidebarVisible(false); 618 + }} 619 + onSettingsPress={() => { 620 + setCurrentView('settings'); 621 + setSidebarVisible(false); 622 + }} 623 + onThemeToggle={toggleColorScheme} 624 + onRefreshAgent={handleRefreshAgent} 625 + onLogout={logout} 626 + /> 627 + </> 628 + )} 439 629 </View> 440 630 </View> 441 631 ); ··· 456 646 container: { 457 647 flex: 1, 458 648 }, 649 + appLayout: { 650 + flex: 1, 651 + flexDirection: 'row', 652 + }, 653 + appLayoutNarrow: { 654 + flexDirection: 'column', 655 + }, 459 656 mainContent: { 460 657 flex: 1, 461 658 }, ··· 466 663 flex: 1, 467 664 justifyContent: 'center', 468 665 alignItems: 'center', 666 + }, 667 + backdrop: { 668 + position: 'absolute', 669 + top: 0, 670 + left: 0, 671 + right: 0, 672 + bottom: 0, 673 + backgroundColor: 'black', 674 + zIndex: 999, 469 675 }, 470 676 });
+31 -4
src/components/AppSidebar.tsx
··· 38 38 developerMode: boolean; 39 39 agentId?: string; 40 40 currentView: ViewType; 41 + isOverlay: boolean; // Whether sidebar is overlay mode (narrow screens) or push mode (wide screens) 41 42 onClose: () => void; 42 43 onYouPress: () => void; 43 44 onChatPress: () => void; ··· 56 57 developerMode, 57 58 agentId, 58 59 currentView, 60 + isOverlay, 59 61 onClose, 60 62 onYouPress, 61 63 onChatPress, ··· 99 101 return ( 100 102 <Animated.View 101 103 style={[ 102 - styles.sidebarContainer, 104 + isOverlay ? styles.sidebarOverlay : styles.sidebarContainer, 103 105 { 104 106 paddingTop: insets.top, 105 107 backgroundColor: theme.colors.background.secondary, 106 108 borderRightColor: theme.colors.border.primary, 107 - width: animationValue.interpolate({ 108 - inputRange: [0, 1], 109 - outputRange: [0, 280], 109 + ...(isOverlay 110 + ? { 111 + // Overlay mode: slide in from left with fixed width 112 + transform: [ 113 + { 114 + translateX: animationValue.interpolate({ 115 + inputRange: [0, 1], 116 + outputRange: [-280, 0], 117 + }), 118 + }, 119 + ], 120 + } 121 + : { 122 + // Push mode: animate width 123 + width: animationValue.interpolate({ 124 + inputRange: [0, 1], 125 + outputRange: [0, 280], 126 + }), 127 + }), 128 + opacity: animationValue.interpolate({ 129 + inputRange: [0, 0.3, 1], 130 + outputRange: [0, 0.8, 1], 110 131 }), 111 132 }, 112 133 ]} ··· 311 332 312 333 const styles = StyleSheet.create({ 313 334 sidebarContainer: { 335 + height: '100%', 336 + borderRightWidth: 1, 337 + overflow: 'hidden', 338 + }, 339 + sidebarOverlay: { 314 340 position: 'absolute', 315 341 left: 0, 316 342 top: 0, 317 343 bottom: 0, 344 + width: 280, 318 345 zIndex: 1000, 319 346 borderRightWidth: 1, 320 347 overflow: 'hidden',