A React Native app for the ultimate thinking partner.

feat: rebrand to "co" with comprehensive UI/UX improvements

Major changes:
- Rebrand from Ion to co (lowercase, minimal branding)
- Simplify architecture by removing project/agent selection screens
- Auto-create single "co" agent on login for streamlined experience

UI/UX enhancements:
- Smooth streaming with token buffering (50 FPS, 1-3 chars at a time)
- Animated spacer that grows to push user messages up, giving room for responses
- Inverted text input styling (white bg/black text in dark mode)
- Theme toggle between light/dark modes
- Increased border radius for softer appearance (24px bubbles, 28px input)
- Single-line height text input with improved padding
- Streaming indicator (hollow circle) at end of text

Technical improvements:
- Token buffer drains naturally before finalizing messages (no flash)
- Streaming container uses minHeight to reserve space smoothly
- Web-specific CSS for focus states and theme-aware styling
- Data attributes for theme targeting instead of media queries
- Removed complex polling logic in favor of ref-based completion tracking

Removed legacy code:
- Agent/project selection screens
- Unused navigation components
- Old sidebar and settings screens
- Outdated documentation files

+1901 -2358
+1111 -2177
App.tsx
··· 6 6 TouchableOpacity, 7 7 Alert, 8 8 TextInput, 9 - ScrollView, 10 9 FlatList, 11 10 SafeAreaView, 12 11 ActivityIndicator, 13 12 Modal, 14 - Linking, 15 13 Dimensions, 16 14 useColorScheme, 17 15 Platform, 16 + Linking, 17 + Animated, 18 18 } from 'react-native'; 19 19 import { Ionicons } from '@expo/vector-icons'; 20 20 import { StatusBar } from 'expo-status-bar'; 21 - import { useFonts } from 'expo-font'; 22 21 import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; 22 + import { useFonts, Lexend_400Regular, Lexend_500Medium, Lexend_600SemiBold, Lexend_700Bold } from '@expo-google-fonts/lexend'; 23 23 import LogoLoader from './src/components/LogoLoader'; 24 - import Wordmark from './src/components/Wordmark'; 25 - // Wordmark is used in the sidebar, not in the chat header 26 24 import lettaApi from './src/api/lettaApi'; 27 25 import Storage, { STORAGE_KEYS } from './src/utils/storage'; 28 - import CreateAgentScreen from './CreateAgentScreen'; 29 - import AgentSelectorScreen from './AgentSelectorScreen'; 30 - import ProjectSelectorModal from './ProjectSelectorModal'; 31 - import Sidebar from './src/components/Sidebar'; 26 + import { findOrCreateCo } from './src/utils/coAgent'; 27 + import CoLoginScreen from './CoLoginScreen'; 32 28 import MessageContent from './src/components/MessageContent'; 33 29 import ExpandableMessageContent from './src/components/ExpandableMessageContent'; 30 + import AnimatedStreamingText from './src/components/AnimatedStreamingText'; 34 31 import ToolCallItem from './src/components/ToolCallItem'; 35 - import { darkTheme } from './src/theme'; 36 - import type { LettaAgent, LettaMessage, StreamingChunk, Project, MemoryBlock } from './src/types/letta'; 37 - import useAppStore from './src/store/appStore'; 32 + import { darkTheme, lightTheme, CoColors } from './src/theme'; 33 + import type { LettaAgent, LettaMessage, StreamingChunk, MemoryBlock } from './src/types/letta'; 34 + 35 + // Import web styles for transparent input 36 + if (Platform.OS === 'web') { 37 + require('./web-styles.css'); 38 + } 38 39 39 - function MainApp() { 40 + function CoApp() { 40 41 const insets = useSafeAreaInsets(); 41 - const colorScheme = useColorScheme(); 42 + const systemColorScheme = useColorScheme(); 43 + const [colorScheme, setColorScheme] = useState<'light' | 'dark'>(systemColorScheme || 'dark'); 44 + 45 + const [fontsLoaded] = useFonts({ 46 + Lexend_400Regular, 47 + Lexend_500Medium, 48 + Lexend_600SemiBold, 49 + Lexend_700Bold, 50 + }); 51 + 52 + const toggleColorScheme = () => { 53 + setColorScheme(prev => prev === 'dark' ? 'light' : 'dark'); 54 + }; 55 + 56 + const theme = colorScheme === 'dark' ? darkTheme : lightTheme; 57 + 42 58 // Authentication state 43 59 const [apiToken, setApiToken] = useState(''); 44 60 const [isConnected, setIsConnected] = useState(false); 45 61 const [isConnecting, setIsConnecting] = useState(false); 46 62 const [isLoadingToken, setIsLoadingToken] = useState(true); 47 - 48 - // Project state 49 - const [currentProject, setCurrentProject] = useState<Project | null>(null); 50 - const [showProjectSelector, setShowProjectSelector] = useState(false); 51 - 52 - // Agent state 53 - const [agents, setAgents] = useState<LettaAgent[]>([]); 54 - const [currentAgent, setCurrentAgent] = useState<LettaAgent | null>(null); 55 - const [showAgentSelector, setShowAgentSelector] = useState(true); // Start with agent selector 56 - const [showCreateAgentScreen, setShowCreateAgentScreen] = useState(false); 57 - const [showChatView, setShowChatView] = useState(false); 58 - 63 + const [connectionError, setConnectionError] = useState<string | null>(null); 64 + 65 + // Co agent state 66 + const [coAgent, setCoAgent] = useState<LettaAgent | null>(null); 67 + const [isInitializingCo, setIsInitializingCo] = useState(false); 68 + 59 69 // Message state 60 70 const [messages, setMessages] = useState<LettaMessage[]>([]); 61 71 const PAGE_SIZE = 50; 62 - const INITIAL_LOAD_LIMIT = 20; // load only the last N messages initially 72 + const INITIAL_LOAD_LIMIT = 20; 63 73 const [earliestCursor, setEarliestCursor] = useState<string | null>(null); 64 74 const [hasMoreBefore, setHasMoreBefore] = useState<boolean>(false); 65 75 const [isLoadingMore, setIsLoadingMore] = useState<boolean>(false); 66 76 const [inputText, setInputText] = useState(''); 67 77 const [isSendingMessage, setIsSendingMessage] = useState(false); 68 78 const [isLoadingMessages, setIsLoadingMessages] = useState(false); 69 - 79 + 70 80 // Streaming state 71 81 const [streamingMessage, setStreamingMessage] = useState<string>(''); 72 82 const [isStreaming, setIsStreaming] = useState(false); 73 83 const [streamingStep, setStreamingStep] = useState<string>(''); 84 + const [streamingMessageId, setStreamingMessageId] = useState<string>(''); 85 + const [streamingReasoning, setStreamingReasoning] = useState<string>(''); 86 + const [lastMessageNeedsSpace, setLastMessageNeedsSpace] = useState(false); 87 + const spacerHeightAnim = useRef(new Animated.Value(0)).current; 88 + const streamCompleteRef = useRef(false); 89 + 90 + // Token buffering for smooth streaming 91 + const tokenBufferRef = useRef<string>(''); 92 + const bufferIntervalRef = useRef<NodeJS.Timeout | null>(null); 93 + const scrollIntervalRef = useRef<NodeJS.Timeout | null>(null); 94 + 74 95 // HITL approval state 75 96 const [approvalVisible, setApprovalVisible] = useState(false); 76 97 const [approvalData, setApprovalData] = useState<{ ··· 81 102 } | null>(null); 82 103 const [approvalReason, setApprovalReason] = useState(''); 83 104 const [isApproving, setIsApproving] = useState(false); 84 - // Track in-progress tool messages per step so we can accumulate 105 + 85 106 const toolCallMsgIdsRef = useRef<Map<string, string>>(new Map()); 86 107 const toolReturnMsgIdsRef = useRef<Map<string, string>>(new Map()); 87 108 88 109 // Layout state for responsive design 89 110 const [screenData, setScreenData] = useState(Dimensions.get('window')); 90 111 const [sidebarVisible, setSidebarVisible] = useState(false); 91 - const { favorites, toggleFavorite, isFavorite } = useAppStore(); 92 - const [activeSidebarTab, setActiveSidebarTab] = useState<'project' | 'favorites' | 'memory'>('project'); 112 + const [activeSidebarTab, setActiveSidebarTab] = useState<'memory'>('memory'); 93 113 const [memoryBlocks, setMemoryBlocks] = useState<MemoryBlock[]>([]); 94 114 const [isLoadingBlocks, setIsLoadingBlocks] = useState(false); 95 115 const [blocksError, setBlocksError] = useState<string | null>(null); 96 116 const [selectedBlock, setSelectedBlock] = useState<MemoryBlock | null>(null); 97 - // Cache and derived name for the selected agent's project (favorites can cross projects) 98 - const [projectNameCache, setProjectNameCache] = useState<Record<string, string>>({}); 99 - const [agentProjectName, setAgentProjectName] = useState<string | undefined>(undefined); 100 117 101 118 const isDesktop = screenData.width >= 768; 102 119 103 120 // Ref for ScrollView to control scrolling 104 121 const scrollViewRef = useRef<FlatList<any>>(null); 105 - // Reserve space and anchor positioning for streaming response 106 - const [bottomSpacerHeight, setBottomSpacerHeight] = useState(0); 107 - const [hasPositionedForStream, setHasPositionedForStream] = useState(false); 108 - // Track scroll position to counteract padding removal jump 109 122 const [scrollY, setScrollY] = useState(0); 110 - // Track sizes to show a quick "scroll to bottom" control 111 123 const [contentHeight, setContentHeight] = useState(0); 112 124 const [containerHeight, setContainerHeight] = useState(0); 113 125 const [showScrollToBottom, setShowScrollToBottom] = useState(false); 114 126 const [inputContainerHeight, setInputContainerHeight] = useState(0); 115 - // Track when we need to jump to the bottom after first load/render 116 127 const pendingJumpToBottomRef = useRef<boolean>(false); 117 128 const pendingJumpRetriesRef = useRef<number>(0); 118 129 119 - // Smoothly shrink the bottom spacer and counter-scroll to avoid visual jumps 120 - const smoothRemoveSpacer = (durationMs: number = 220) => { 121 - const start = Date.now(); 122 - const initial = bottomSpacerHeight; 123 - if (initial <= 0) return; 124 - const step = () => { 125 - const elapsed = Date.now() - start; 126 - const t = Math.min(1, elapsed / durationMs); 127 - // ease-out cubic 128 - const eased = 1 - Math.pow(1 - t, 3); 129 - const newH = Math.round(initial * (1 - eased)); 130 - const delta = bottomSpacerHeight - newH; // amount removed this frame 131 - if (delta !== 0) { 132 - // Counteract layout shift by adjusting scroll position upward by the same delta 133 - const targetY = Math.max(0, scrollY - delta); 134 - scrollViewRef.current?.scrollToOffset({ offset: targetY, animated: false }); 135 - setScrollY(targetY); 136 - setBottomSpacerHeight(newH); 130 + // Load stored API token on mount 131 + useEffect(() => { 132 + loadStoredToken(); 133 + }, []); 134 + 135 + // Cleanup intervals on unmount 136 + useEffect(() => { 137 + return () => { 138 + if (bufferIntervalRef.current) { 139 + clearInterval(bufferIntervalRef.current); 137 140 } 138 - if (t < 1) { 139 - requestAnimationFrame(step); 141 + if (scrollIntervalRef.current) { 142 + clearInterval(scrollIntervalRef.current); 140 143 } 141 144 }; 142 - requestAnimationFrame(step); 143 - }; 145 + }, []); 144 146 145 - // Helper: update FAB visibility when user is not near bottom 146 - const updateScrollButtonVisibility = (offsetY: number) => { 147 - const threshold = 80; // px from bottom 148 - const distanceFromBottom = Math.max(0, contentHeight - (offsetY + containerHeight)); 149 - setShowScrollToBottom(distanceFromBottom > threshold); 150 - }; 147 + // Initialize Co when connected 148 + useEffect(() => { 149 + if (isConnected && !coAgent && !isInitializingCo) { 150 + initializeCo(); 151 + } 152 + }, [isConnected, coAgent, isInitializingCo]); 151 153 152 - const handleScroll = (e: any) => { 153 - const y = e.nativeEvent.contentOffset.y; 154 - setScrollY(y); 155 - updateScrollButtonVisibility(y); 156 - }; 154 + // Load messages when Co agent is ready 155 + useEffect(() => { 156 + if (coAgent) { 157 + loadMessages(); 158 + } 159 + }, [coAgent]); 157 160 158 - const handleContentSizeChange = (_w: number, h: number) => { 159 - setContentHeight(h); 160 - updateScrollButtonVisibility(scrollY); 161 - // If a bottom jump is pending and we now know content height, jump precisely 162 - if (pendingJumpToBottomRef.current && containerHeight > 0 && pendingJumpRetriesRef.current > 0) { 163 - const offset = Math.max(0, h - containerHeight); 164 - scrollViewRef.current?.scrollToOffset({ offset, animated: false }); 165 - setShowScrollToBottom(false); 166 - pendingJumpRetriesRef.current -= 1; 167 - if (pendingJumpRetriesRef.current <= 0) pendingJumpToBottomRef.current = false; 161 + const loadStoredToken = async () => { 162 + try { 163 + const stored = await Storage.getItem(STORAGE_KEYS.API_TOKEN); 164 + if (stored) { 165 + setApiToken(stored); 166 + await connectWithToken(stored); 167 + } 168 + } catch (error) { 169 + console.error('Failed to load stored token:', error); 170 + } finally { 171 + setIsLoadingToken(false); 168 172 } 169 173 }; 170 174 171 - const handleMessagesLayout = (e: any) => { 172 - const h = e.nativeEvent.layout.height; 173 - setContainerHeight(h); 174 - updateScrollButtonVisibility(scrollY); 175 - // If a bottom jump is pending and we now know container height, jump precisely 176 - if (pendingJumpToBottomRef.current && contentHeight > 0 && pendingJumpRetriesRef.current > 0) { 177 - const offset = Math.max(0, contentHeight - h); 178 - scrollViewRef.current?.scrollToOffset({ offset, animated: false }); 179 - setShowScrollToBottom(false); 180 - pendingJumpRetriesRef.current -= 1; 181 - if (pendingJumpRetriesRef.current <= 0) pendingJumpToBottomRef.current = false; 175 + const connectWithToken = async (token: string) => { 176 + setIsConnecting(true); 177 + setConnectionError(null); 178 + try { 179 + lettaApi.setAuthToken(token); 180 + const isValid = await lettaApi.testConnection(); 181 + 182 + if (isValid) { 183 + setIsConnected(true); 184 + await Storage.setItem(STORAGE_KEYS.API_TOKEN, token); 185 + } else { 186 + throw new Error('Invalid API token'); 187 + } 188 + } catch (error: any) { 189 + console.error('Connection failed:', error); 190 + setConnectionError(error.message || 'Failed to connect'); 191 + lettaApi.removeAuthToken(); 192 + setIsConnected(false); 193 + } finally { 194 + setIsConnecting(false); 182 195 } 183 196 }; 184 197 185 - const scrollToBottom = () => { 186 - scrollViewRef.current?.scrollToEnd({ animated: true }); 187 - setShowScrollToBottom(false); 198 + const handleLogin = async (token: string) => { 199 + setApiToken(token); 200 + await connectWithToken(token); 188 201 }; 189 202 190 - const handleInputLayout = (e: any) => { 191 - setInputContainerHeight(e.nativeEvent.layout.height || 0); 203 + const handleLogout = async () => { 204 + Alert.alert( 205 + 'Logout', 206 + 'Are you sure you want to log out?', 207 + [ 208 + { text: 'Cancel', style: 'cancel' }, 209 + { 210 + text: 'Logout', 211 + style: 'destructive', 212 + onPress: async () => { 213 + await Storage.removeItem(STORAGE_KEYS.API_TOKEN); 214 + lettaApi.removeAuthToken(); 215 + setApiToken(''); 216 + setIsConnected(false); 217 + setCoAgent(null); 218 + setMessages([]); 219 + setConnectionError(null); 220 + }, 221 + }, 222 + ] 223 + ); 192 224 }; 193 225 194 - // No-op: header segmented control switches between Memory and Chat views 226 + const initializeCo = async () => { 227 + setIsInitializingCo(true); 228 + try { 229 + console.log('Initializing Co agent...'); 230 + const agent = await findOrCreateCo('User'); 231 + setCoAgent(agent); 232 + console.log('Co agent ready:', agent.id); 233 + } catch (error: any) { 234 + console.error('Failed to initialize Co:', error); 235 + Alert.alert('Error', 'Failed to initialize Co: ' + (error.message || 'Unknown error')); 236 + } finally { 237 + setIsInitializingCo(false); 238 + } 239 + }; 195 240 196 - // Group messages for efficient FlatList rendering (pairs tool call + result, attach reasoning) 197 - type MessageGroup = 198 - | { key: string; type: 'toolPair'; call: LettaMessage; ret?: LettaMessage; reasoning?: string } 199 - | { key: string; type: 'message'; message: LettaMessage; reasoning?: string }; 241 + const loadMessages = async (before?: string) => { 242 + if (!coAgent) return; 200 243 201 - const messageGroups: MessageGroup[] = useMemo(() => { 202 - const groups: MessageGroup[] = []; 203 - const callTypes = new Set(['tool_call', 'tool_call_message', 'tool_message']); 204 - const retTypes = new Set(['tool_response', 'tool_return_message']); 205 - const shownReasoningForStep = new Set<string>(); 206 - 207 - const findReasoningForStep = (fromIndex: number, stepId?: string): string | undefined => { 208 - if (!stepId) return undefined; 209 - for (let j = fromIndex; j < messages.length; j++) { 210 - const msgAny: any = messages[j]; 211 - if (msgAny.role === 'assistant' && msgAny.step_id && String(msgAny.step_id) === String(stepId) && msgAny.reasoning) { 212 - return String(msgAny.reasoning); 213 - } 244 + try { 245 + if (!before) { 246 + setIsLoadingMessages(true); 247 + } else { 248 + setIsLoadingMore(true); 214 249 } 215 - return undefined; 216 - }; 217 250 218 - for (let i = 0; i < messages.length; i++) { 219 - const m: any = messages[i]; 220 - if (m.role === 'tool' && callTypes.has(m.message_type)) { 221 - const next: any = messages[i + 1]; 222 - const paired = !!(next && next.role === 'tool' && retTypes.has(next.message_type) && ( 223 - (next.step_id && m.step_id && String(next.step_id) === String(m.step_id)) || 224 - (!m.step_id || m.step_id === 'no-step') 225 - )); 251 + const loadedMessages = await lettaApi.listMessages(coAgent.id, { 252 + before: before || undefined, 253 + limit: before ? PAGE_SIZE : INITIAL_LOAD_LIMIT, 254 + use_assistant_message: true, 255 + }); 226 256 227 - let reasoningToShow: string | undefined = m.reasoning; 228 - if (!reasoningToShow && m.step_id && !shownReasoningForStep.has(m.step_id)) { 229 - reasoningToShow = findReasoningForStep(i + 1, m.step_id); 257 + if (loadedMessages.length > 0) { 258 + if (before) { 259 + setMessages(prev => [...loadedMessages, ...prev]); 260 + setEarliestCursor(loadedMessages[0].id); 261 + } else { 262 + setMessages(loadedMessages); 263 + if (loadedMessages.length > 0) { 264 + setEarliestCursor(loadedMessages[0].id); 265 + pendingJumpToBottomRef.current = true; 266 + pendingJumpRetriesRef.current = 3; 267 + } 230 268 } 231 - 232 - groups.push({ 233 - key: `${m.id}-grp-${i}`, 234 - type: 'toolPair', 235 - call: m, 236 - ret: paired ? next : undefined, 237 - reasoning: reasoningToShow, 238 - }); 239 - 240 - if (m.step_id && reasoningToShow) shownReasoningForStep.add(m.step_id); 241 - if (paired) { i++; } 242 - continue; 269 + setHasMoreBefore(loadedMessages.length === (before ? PAGE_SIZE : INITIAL_LOAD_LIMIT)); 270 + } else { 271 + setHasMoreBefore(false); 243 272 } 244 - 245 - const reasoning = (m.role === 'assistant' && (m as any).reasoning && (!m.step_id || !shownReasoningForStep.has(m.step_id))) 246 - ? (m as any).reasoning 247 - : undefined; 248 - 249 - groups.push({ key: `${m.id || 'msg'}-${i}-${m.created_at}`, type: 'message', message: m, reasoning }); 250 - if (m.role === 'assistant' && (m as any).reasoning && m.step_id) shownReasoningForStep.add(m.step_id); 273 + } catch (error: any) { 274 + console.error('Failed to load messages:', error); 275 + Alert.alert('Error', 'Failed to load messages: ' + (error.message || 'Unknown error')); 276 + } finally { 277 + setIsLoadingMessages(false); 278 + setIsLoadingMore(false); 251 279 } 252 - return groups; 253 - }, [messages]); 254 - 255 - // Handle message expansion toggle (no forced scroll to avoid flicker) 256 - const handleMessageToggle = useCallback((_expanding: boolean) => { 257 - // Intentionally no-op; FlatList will handle re-layout. 258 - // Keeping this hook to allow future tweaks without re-plumbing props. 259 - }, []); 260 - 261 - const renderGroupItem = ({ item }: { item: MessageGroup }) => { 262 - if (item.type === 'toolPair') { 263 - return ( 264 - <View style={styles.messageGroup}> 265 - {!!item.reasoning && ( 266 - <View style={styles.reasoningContainer}> 267 - <Text style={styles.reasoningLabel}>Reasoning</Text> 268 - <Text style={styles.reasoningText}>{item.reasoning}</Text> 269 - </View> 270 - )} 271 - <View style={[styles.message, styles.assistantMessage]}> 272 - <ToolCallItem callText={item.call.content} resultText={item.ret?.content} /> 273 - </View> 274 - </View> 275 - ); 276 - } 277 - const m = item.message as any; 278 - return ( 279 - <View style={styles.messageGroup}> 280 - {!!item.reasoning && ( 281 - <View style={styles.reasoningContainer}> 282 - <Text style={styles.reasoningLabel}>Reasoning</Text> 283 - <Text style={styles.reasoningText}>{item.reasoning}</Text> 284 - </View> 285 - )} 286 - <View style={[styles.message, m.role === 'user' ? styles.userMessage : styles.assistantMessage]}> 287 - {m.role === 'tool' ? ( 288 - <Text style={styles.messageText}>{m.content}</Text> 289 - ) : ( 290 - <ExpandableMessageContent 291 - content={m.content} 292 - isUser={m.role === 'user'} 293 - onToggle={handleMessageToggle} 294 - /> 295 - )} 296 - </View> 297 - </View> 298 - ); 299 280 }; 300 281 301 - // Format tool arguments in a compact Python-style signature 302 - const formatArgsPython = (raw: any): string => { 303 - try { 304 - if (!raw) return ''; 305 - const obj = typeof raw === 'string' ? JSON.parse(raw) : raw; 306 - if (Array.isArray(obj)) { 307 - return obj.map(v => JSON.stringify(v)).join(', '); 308 - } 309 - if (typeof obj === 'object') { 310 - return Object.entries(obj) 311 - .map(([k, v]) => `${k}=${typeof v === 'string' ? JSON.stringify(v) : JSON.stringify(v)}`) 312 - .join(', '); 313 - } 314 - return String(raw); 315 - } catch { 316 - // Fallback to raw string (truncated) 317 - return typeof raw === 'string' ? raw : JSON.stringify(raw); 282 + const loadMoreMessages = () => { 283 + if (hasMoreBefore && !isLoadingMore && earliestCursor) { 284 + loadMessages(earliestCursor); 318 285 } 319 286 }; 320 287 321 - // Load saved token on app startup 322 - useEffect(() => { 323 - const loadSavedToken = async () => { 324 - try { 325 - console.log(`Using storage type: ${Storage.getStorageType()}`); 326 - const savedToken = await Storage.getItem(STORAGE_KEYS.TOKEN); 327 - if (savedToken) { 328 - console.log('Found saved token, attempting auto-login'); 329 - lettaApi.setAuthToken(savedToken); 330 - const isValid = await lettaApi.testConnection(); 331 - 332 - if (isValid) { 333 - setApiToken(savedToken); 334 - setIsConnected(true); 335 - const project = await loadSavedProject(); 336 - if (project) { 337 - await loadSavedAgent(project); 338 - } 339 - console.log('Auto-login successful'); 340 - } else { 341 - console.log('Saved token is invalid, clearing it'); 342 - await Storage.removeItem(STORAGE_KEYS.TOKEN); 343 - } 344 - } 345 - } catch (error) { 346 - console.error('Error loading saved token:', error); 347 - } finally { 348 - setIsLoadingToken(false); 349 - } 350 - }; 288 + const sendMessage = async () => { 289 + if (!inputText.trim() || !coAgent || isSendingMessage) return; 351 290 352 - const loadSavedProject = async () => { 353 - try { 354 - const savedProjectId = await Storage.getItem(STORAGE_KEYS.PROJECT_ID); 355 - const savedProjectName = await Storage.getItem(STORAGE_KEYS.PROJECT_NAME); 291 + const messageText = inputText.trim(); 292 + setInputText(''); 293 + setIsSendingMessage(true); 356 294 357 - if (savedProjectId && savedProjectName) { 358 - console.log(`Restoring project: ${savedProjectName} (ID: ${savedProjectId})`); 295 + // Remove space from previous message before adding new user message 296 + setLastMessageNeedsSpace(false); 297 + spacerHeightAnim.setValue(0); 359 298 360 - // Use name query parameter for direct lookup 361 - const response = await lettaApi.listProjects({ name: savedProjectName }); 362 - const foundProject = response.projects.find(p => p.id === savedProjectId); 299 + // Immediately add user message to UI 300 + const tempUserMessage: LettaMessage = { 301 + id: `temp-${Date.now()}`, 302 + role: 'user', 303 + content: messageText, 304 + date: new Date().toISOString(), 305 + } as LettaMessage; 363 306 364 - if (foundProject) { 365 - setCurrentProject(foundProject); 366 - console.log('Restored saved project:', foundProject.name); 367 - return foundProject; 368 - } else { 369 - console.log('Saved project not found by name query, clearing cached data'); 370 - await Storage.removeItem(STORAGE_KEYS.PROJECT_ID); 371 - await Storage.removeItem(STORAGE_KEYS.PROJECT_NAME); 372 - } 373 - } else if (savedProjectId && !savedProjectName) { 374 - // Legacy case: we have ID but no name - fall back to pagination search once 375 - console.log('Legacy project ID found without name, doing one-time migration'); 307 + setMessages(prev => [...prev, tempUserMessage]); 376 308 377 - let foundProject = null; 378 - let limit = 100; 379 - let hasNextPage = true; 380 - let offset = 0; 309 + // Scroll to bottom immediately to show user message 310 + setTimeout(() => { 311 + scrollViewRef.current?.scrollToEnd({ animated: false }); 312 + }, 50); 381 313 382 - while (!foundProject && hasNextPage && offset < 500) { 383 - const response = await lettaApi.listProjects({ limit, offset }); 384 - foundProject = response.projects.find(p => p.id === savedProjectId); 314 + try { 315 + setIsStreaming(true); 316 + setLastMessageNeedsSpace(true); 317 + setStreamingMessage(''); 318 + setStreamingStep(''); 319 + setStreamingMessageId(''); 320 + setStreamingReasoning(''); 321 + tokenBufferRef.current = ''; 322 + streamCompleteRef.current = false; 385 323 386 - if (foundProject) { 387 - // Migrate to new storage format with both ID and name 388 - await Storage.setItem(STORAGE_KEYS.PROJECT_NAME, foundProject.name); 389 - setCurrentProject(foundProject); 390 - console.log('Migrated and restored project:', foundProject.name); 391 - return foundProject; 392 - } 324 + // Animate spacer growing to push user message up (push previous content out of view) 325 + const targetHeight = Math.max(containerHeight * 0.9, 450); 326 + spacerHeightAnim.setValue(0); 393 327 394 - hasNextPage = response.hasNextPage; 395 - offset += limit; 396 - } 328 + Animated.timing(spacerHeightAnim, { 329 + toValue: targetHeight, 330 + duration: 400, 331 + useNativeDriver: false, // height animation can't use native driver 332 + }).start(); 397 333 398 - // Clean up if not found 399 - if (!foundProject) { 400 - console.log('Legacy project not found, clearing it'); 401 - await Storage.removeItem(STORAGE_KEYS.PROJECT_ID); 402 - } 403 - } 404 - } catch (error) { 405 - console.error('Error loading saved project:', error); 334 + // During animation, keep scroll at bottom 335 + if (scrollIntervalRef.current) { 336 + clearInterval(scrollIntervalRef.current); 406 337 } 407 - return null; 408 - }; 338 + scrollIntervalRef.current = setInterval(() => { 339 + scrollViewRef.current?.scrollToEnd({ animated: false }); 340 + }, 16); // ~60fps 409 341 410 - const loadSavedAgent = async (project: Project) => { 411 - try { 412 - const savedAgentId = await Storage.getItem(STORAGE_KEYS.AGENT_ID); 413 - if (savedAgentId && project) { 414 - const agentList = await lettaApi.listAgentsForProject(project.id, { 415 - limit: 50, 416 - }); 417 - const foundAgent = agentList.find(a => a.id === savedAgentId); 418 - if (foundAgent) { 419 - setCurrentAgent(foundAgent); 420 - setShowAgentSelector(false); 421 - setShowChatView(true); 422 - await loadMessagesForAgent(foundAgent.id); 423 - console.log('Restored saved agent:', foundAgent.name); 424 - return foundAgent; 425 - } else { 426 - console.log('Saved agent not found, clearing it'); 427 - await Storage.removeItem(STORAGE_KEYS.AGENT_ID); 428 - } 342 + setTimeout(() => { 343 + if (scrollIntervalRef.current) { 344 + clearInterval(scrollIntervalRef.current); 345 + scrollIntervalRef.current = null; 429 346 } 430 - } catch (error) { 431 - console.error('Error loading saved agent:', error); 347 + }, 400); 348 + 349 + // Start smooth token release interval 350 + if (bufferIntervalRef.current) { 351 + clearInterval(bufferIntervalRef.current); 432 352 } 433 - return null; 434 - }; 353 + bufferIntervalRef.current = setInterval(() => { 354 + if (tokenBufferRef.current.length > 0) { 355 + // Release 1-3 characters at a time for smooth effect 356 + const chunkSize = Math.min(3, tokenBufferRef.current.length); 357 + const chunk = tokenBufferRef.current.slice(0, chunkSize); 358 + tokenBufferRef.current = tokenBufferRef.current.slice(chunkSize); 359 + setStreamingMessage(prev => prev + chunk); 360 + } else if (streamCompleteRef.current) { 361 + // Buffer is empty and streaming is done - finalize 362 + if (bufferIntervalRef.current) { 363 + clearInterval(bufferIntervalRef.current); 364 + bufferIntervalRef.current = null; 365 + } 366 + 367 + setStreamingMessage(currentContent => { 368 + setIsStreaming(false); 369 + setStreamingStep(''); 370 + 371 + const finalMessage: LettaMessage = { 372 + id: streamingMessageId || `msg_${Date.now()}`, 373 + role: 'assistant', 374 + content: currentContent, 375 + created_at: new Date().toISOString(), 376 + reasoning: streamingReasoning || undefined, 377 + }; 435 378 379 + setMessages(prev => [...prev, finalMessage]); 380 + setStreamingMessageId(''); 381 + setStreamingReasoning(''); 436 382 437 - loadSavedToken(); 438 - }, []); 383 + return ''; 384 + }); 385 + } 386 + }, 20); // 50 FPS 439 387 440 - // Listen for orientation/screen size changes 441 - useEffect(() => { 442 - const onChange = (result: any) => { 443 - setScreenData(result.window); 444 - }; 388 + toolCallMsgIdsRef.current.clear(); 389 + toolReturnMsgIdsRef.current.clear(); 445 390 446 - const subscription = Dimensions.addEventListener('change', onChange); 447 - return () => subscription?.remove(); 448 - }, []); 391 + await lettaApi.sendMessageStream( 392 + coAgent.id, 393 + { 394 + messages: [{ role: 'user', content: messageText }], 395 + use_assistant_message: true, 396 + stream_tokens: true, 397 + }, 398 + (chunk: StreamingChunk) => { 399 + handleStreamingChunk(chunk); 400 + }, 401 + async (response) => { 402 + console.log('Stream complete'); 403 + // Signal that streaming is done - buffer interval will finalize when empty 404 + streamCompleteRef.current = true; 405 + }, 406 + (error) => { 407 + console.error('Streaming error:', error); 449 408 450 - // Load memory blocks when Memory tab is active 451 - useEffect(() => { 452 - const loadBlocks = async () => { 453 - if (!currentAgent || activeSidebarTab !== 'memory') return; 454 - setIsLoadingBlocks(true); 455 - setBlocksError(null); 456 - try { 457 - const blocks = await lettaApi.listAgentBlocks(currentAgent.id); 458 - const sorted = (blocks || []).slice().sort((a, b) => { 459 - const la = (a.label || a.name || '').toLowerCase(); 460 - const lb = (b.label || b.name || '').toLowerCase(); 461 - return la.localeCompare(lb); 462 - }); 463 - setMemoryBlocks(sorted); 464 - } catch (e: any) { 465 - setBlocksError(e?.message || 'Failed to load memory blocks'); 466 - } finally { 467 - setIsLoadingBlocks(false); 468 - } 469 - }; 470 - loadBlocks(); 471 - }, [activeSidebarTab, currentAgent?.id]); 409 + // Clear intervals on error 410 + if (bufferIntervalRef.current) { 411 + clearInterval(bufferIntervalRef.current); 412 + bufferIntervalRef.current = null; 413 + } 414 + if (scrollIntervalRef.current) { 415 + clearInterval(scrollIntervalRef.current); 416 + scrollIntervalRef.current = null; 417 + } 472 418 473 - const handleConnect = async () => { 474 - const trimmedToken = apiToken.trim(); 475 - if (!trimmedToken) { 476 - Alert.alert('Error', 'Please enter your API token'); 477 - return; 478 - } 419 + // Reset spacer animation 420 + spacerHeightAnim.setValue(0); 421 + streamCompleteRef.current = false; 479 422 480 - setIsConnecting(true); 481 - try { 482 - lettaApi.setAuthToken(trimmedToken); 483 - const isValid = await lettaApi.testConnection(); 484 - 485 - if (isValid) { 486 - // Save token securely 487 - await Storage.setItem(STORAGE_KEYS.TOKEN, trimmedToken); 488 - console.log(`Token saved securely using ${Storage.getStorageType()}`); 489 - 490 - setIsConnected(true); 491 - Alert.alert('Connected', 'Successfully connected to Letta API!'); 492 - } else { 493 - Alert.alert('Error', 'Invalid API token. Please check your credentials.'); 494 - } 423 + setIsStreaming(false); 424 + setStreamingMessage(''); 425 + setStreamingStep(''); 426 + setStreamingMessageId(''); 427 + setStreamingReasoning(''); 428 + tokenBufferRef.current = ''; 429 + Alert.alert('Error', 'Failed to send message: ' + (error.message || 'Unknown error')); 430 + } 431 + ); 495 432 } catch (error: any) { 496 - console.error('Connection error:', error); 497 - Alert.alert('Error', error.message || 'Failed to connect to Letta API'); 433 + console.error('Failed to send message:', error); 434 + Alert.alert('Error', 'Failed to send message: ' + (error.message || 'Unknown error')); 435 + setIsStreaming(false); 436 + spacerHeightAnim.setValue(0); 498 437 } finally { 499 - setIsConnecting(false); 438 + setIsSendingMessage(false); 500 439 } 501 440 }; 502 441 503 - const handleProjectSelect = async (project: Project) => { 504 - try { 505 - await Storage.setItem(STORAGE_KEYS.PROJECT_ID, project.id); 506 - await Storage.setItem(STORAGE_KEYS.PROJECT_NAME, project.name); 507 - setCurrentProject(project); 508 - console.log('Selected project:', project.name); 509 - } catch (error) { 510 - console.error('Error saving selected project:', error); 511 - } 512 - }; 442 + const handleStreamingChunk = (chunk: StreamingChunk) => { 443 + console.log('Streaming chunk:', chunk.message_type, 'content:', chunk.content); 513 444 514 - const handleAgentSelect = async (agent: LettaAgent) => { 515 - try { 516 - await Storage.setItem(STORAGE_KEYS.AGENT_ID, agent.id); 517 - // Reset any pending approvals when switching agents 518 - setApprovalVisible(false); 519 - setApprovalData(null); 520 - setApprovalReason(''); 521 - setCurrentAgent(agent); 522 - setShowAgentSelector(false); 523 - setShowChatView(true); 524 - await loadMessagesForAgent(agent.id); 525 - console.log('Selected agent:', agent.name); 526 - } catch (error) { 527 - console.error('Error selecting agent:', error); 445 + // Capture message ID if present 446 + if (chunk.id && !streamingMessageId) { 447 + setStreamingMessageId(chunk.id); 528 448 } 529 - }; 530 449 531 - const handleOpenAgentInDashboard = () => { 532 - if (!currentAgent) return; 533 - const url = `https://app.letta.com/agents/${encodeURIComponent(currentAgent.id)}`; 534 - Linking.openURL(url); 535 - }; 536 - 537 - const handleBackToAgentSelector = () => { 538 - // Clear any pending approval UI when leaving chat 539 - setApprovalVisible(false); 540 - setApprovalData(null); 541 - setApprovalReason(''); 542 - setShowChatView(false); 543 - setShowAgentSelector(true); 544 - setCurrentAgent(null); 545 - setMessages([]); 546 - }; 547 - 548 - // Keep agentProjectName in sync with currentAgent / currentProject 549 - useEffect(() => { 550 - const resolveProjectName = async () => { 551 - if (!currentAgent?.project_id) { 552 - setAgentProjectName(undefined); 553 - return; 554 - } 555 - const pid = currentAgent.project_id; 556 - // If the global currentProject matches, use its name 557 - if (currentProject && currentProject.id === pid) { 558 - setAgentProjectName(currentProject.name); 559 - return; 560 - } 561 - // Use cache when available 562 - if (projectNameCache[pid]) { 563 - setAgentProjectName(projectNameCache[pid]); 564 - return; 565 - } 566 - // Otherwise, look it up via API (paginate list) 567 - try { 568 - const p = await lettaApi.getProjectById(pid); 569 - if (p?.name) { 570 - setProjectNameCache(prev => ({ ...prev, [pid]: p.name })); 571 - setAgentProjectName(p.name); 572 - } else { 573 - setAgentProjectName(undefined); 450 + if (chunk.message_type === 'assistant_message' && chunk.content) { 451 + // Extract text from content if it's an object 452 + let contentText = ''; 453 + if (typeof chunk.content === 'string') { 454 + contentText = chunk.content; 455 + } else if (typeof chunk.content === 'object' && chunk.content !== null) { 456 + // Handle content array from Letta SDK 457 + if (Array.isArray(chunk.content)) { 458 + contentText = chunk.content 459 + .filter((item: any) => item.type === 'text') 460 + .map((item: any) => item.text || '') 461 + .join(''); 462 + } else if (chunk.content.text) { 463 + contentText = chunk.content.text; 574 464 } 575 - } catch { 576 - setAgentProjectName(undefined); 577 465 } 578 - }; 579 - resolveProjectName(); 580 - }, [currentAgent?.project_id, currentProject?.id]); 581 466 582 - const buildDisplayMessages = (messageHistory: any[]): LettaMessage[] => { 583 - // Filter and transform messages for display (dedupe heartbeats, readable tool steps) 584 - const displayMessages = messageHistory 585 - .filter(msg => { 586 - if (msg.role === 'system') return false; 587 - if (msg.role === 'user' && typeof msg.content === 'string') { 588 - try { 589 - const parsed = JSON.parse(msg.content); 590 - if (parsed?.type === 'heartbeat') return false; 591 - if (parsed?.type === 'system_alert') return false; 592 - } catch {} 593 - } 594 - return true; 595 - }) 596 - .map(msg => { 597 - const call = (msg as any).tool_call || (msg as any).tool_calls?.[0]; 598 - const ret = (msg as any).tool_response || (msg as any).tool_return; 599 - let content = msg.content as any; 600 - if ((!content || typeof content !== 'string') && (msg as any).message_type) { 601 - const mt = (msg as any).message_type; 602 - if (mt === 'tool_call' || mt === 'tool_call_message' || mt === 'tool_message') { 603 - const callObj = call?.function ? call.function : call; 604 - const name = callObj?.name || callObj?.tool_name || 'tool'; 605 - if (name) { 606 - const args = formatArgsPython(callObj?.arguments ?? callObj?.args ?? {}); 607 - content = `${name}(${args})`; 608 - } 609 - } else if (mt === 'tool_response' || mt === 'tool_return_message') { 610 - if (ret != null) { 611 - try { content = typeof ret === 'string' ? ret : JSON.stringify(ret); } catch { content = String(ret) } 612 - } 613 - } 614 - } 615 - if (content != null && typeof content !== 'string') content = String(content); 616 - return { 617 - id: msg.id, 618 - role: msg.role as 'user' | 'assistant' | 'tool', 619 - content: content || '', 620 - created_at: msg.created_at, 621 - reasoning: (msg as any).reasoning, 622 - message_type: (msg as any).message_type, 623 - step_id: (msg as any).step_id != null ? String((msg as any).step_id) : undefined, 624 - } as LettaMessage; 467 + if (contentText) { 468 + // Add to buffer instead of directly to state for smooth streaming 469 + tokenBufferRef.current += contentText; 470 + setStreamingStep(''); 471 + } 472 + } else if (chunk.message_type === 'reasoning_message' && chunk.reasoning) { 473 + // Accumulate reasoning 474 + setStreamingReasoning(prev => prev + chunk.reasoning); 475 + } else if (chunk.message_type === 'tool_call' && chunk.tool_call) { 476 + const toolName = chunk.tool_call.function?.name || 'tool'; 477 + setStreamingStep(`Calling ${toolName}...`); 478 + } else if (chunk.message_type === 'tool_response') { 479 + setStreamingStep('Processing result...'); 480 + } else if (chunk.message_type === 'approval_request_message') { 481 + // Handle approval request 482 + setApprovalData({ 483 + id: chunk.id, 484 + toolName: chunk.tool_call?.function?.name, 485 + toolArgs: chunk.tool_call?.function?.arguments, 486 + reasoning: chunk.reasoning, 625 487 }); 626 - return displayMessages; 488 + setApprovalVisible(true); 489 + } 627 490 }; 628 491 629 - const loadMessagesForAgent = async (agentId: string) => { 630 - setIsLoadingMessages(true); 631 - try { 632 - const messageHistory = await lettaApi.listMessages(agentId, { limit: INITIAL_LOAD_LIMIT }); 633 - console.log('Loaded messages for agent:', messageHistory); 492 + const handleApproval = async (approve: boolean) => { 493 + if (!approvalData?.id || !coAgent) return; 634 494 635 - let displayMessages = buildDisplayMessages(messageHistory); 636 - // Safety: if SDK returns more than requested, keep only the newest N 637 - if (displayMessages.length > INITIAL_LOAD_LIMIT) { 638 - displayMessages = displayMessages.slice(-INITIAL_LOAD_LIMIT); 639 - } 495 + setIsApproving(true); 496 + try { 497 + await lettaApi.approveToolRequest(coAgent.id, { 498 + approval_request_id: approvalData.id, 499 + approve, 500 + reason: approvalReason || undefined, 501 + }); 640 502 641 - setMessages(displayMessages); 642 - setEarliestCursor(displayMessages.length ? displayMessages[0].created_at : null); 643 - setHasMoreBefore(displayMessages.length >= INITIAL_LOAD_LIMIT); 503 + setApprovalVisible(false); 504 + setApprovalData(null); 505 + setApprovalReason(''); 644 506 645 - // If the latest message is an approval request, prompt for approval 646 - const lastRaw: any = messageHistory[messageHistory.length - 1]; 647 - if (lastRaw && lastRaw.message_type === 'approval_request_message' && lastRaw.tool_call) { 648 - try { 649 - const raw = lastRaw.tool_call?.function ? lastRaw.tool_call.function : lastRaw.tool_call; 650 - const args = formatArgsPython(raw?.arguments ?? raw?.args ?? {}); 651 - setApprovalData({ id: lastRaw.id, toolName: raw?.name || raw?.tool_name, toolArgs: args, reasoning: lastRaw.reasoning }); 652 - setApprovalVisible(true); 653 - } catch {} 654 - } else { 655 - setApprovalVisible(false); 656 - setApprovalData(null); 657 - } 658 - 659 - // Defer precise jump until sizes are known via onLayout/onContentSizeChange 660 - pendingJumpToBottomRef.current = true; 661 - pendingJumpRetriesRef.current = 8; // retry across a few layout/size passes 507 + // Continue streaming after approval 662 508 } catch (error: any) { 663 - console.error('Failed to load messages:', error); 664 - Alert.alert('Error', 'Failed to load messages: ' + error.message); 509 + console.error('Approval error:', error); 510 + Alert.alert('Error', 'Failed to process approval: ' + (error.message || 'Unknown error')); 665 511 } finally { 666 - setIsLoadingMessages(false); 512 + setIsApproving(false); 667 513 } 668 514 }; 669 515 670 - const loadOlderMessages = async () => { 671 - if (!currentAgent || isLoadingMore || !hasMoreBefore) return; 516 + const loadMemoryBlocks = async () => { 517 + if (!coAgent) return; 518 + 519 + setIsLoadingBlocks(true); 520 + setBlocksError(null); 672 521 try { 673 - setIsLoadingMore(true); 674 - const history = await lettaApi.listMessages(currentAgent.id, { 675 - limit: PAGE_SIZE, 676 - before: earliestCursor || undefined, 677 - }); 678 - const olderDisplay = buildDisplayMessages(history); 679 - if (olderDisplay.length > 0) { 680 - setMessages(prev => [...olderDisplay, ...prev]); 681 - setEarliestCursor(olderDisplay[0].created_at); 682 - setHasMoreBefore(olderDisplay.length >= PAGE_SIZE); 683 - } else { 684 - setHasMoreBefore(false); 685 - } 686 - } catch (e) { 687 - console.error('Failed to load older messages', e); 688 - setHasMoreBefore(false); 522 + const blocks = await lettaApi.listAgentBlocks(coAgent.id); 523 + setMemoryBlocks(blocks); 524 + } catch (error: any) { 525 + console.error('Failed to load memory blocks:', error); 526 + setBlocksError(error.message || 'Failed to load memory blocks'); 689 527 } finally { 690 - setIsLoadingMore(false); 528 + setIsLoadingBlocks(false); 691 529 } 692 530 }; 693 531 694 - const handleSendMessage = async () => { 695 - if (!inputText.trim() || !currentAgent || isSendingMessage) return; 532 + useEffect(() => { 533 + if (coAgent && sidebarVisible && activeSidebarTab === 'memory') { 534 + loadMemoryBlocks(); 535 + } 536 + }, [coAgent, sidebarVisible, activeSidebarTab]); 696 537 697 - const userMessage: LettaMessage = { 698 - id: `temp-${Date.now()}`, 699 - role: 'user', 700 - content: inputText.trim(), 701 - created_at: new Date().toISOString(), 702 - }; 538 + // State for tracking expanded reasoning 539 + const [expandedReasoning, setExpandedReasoning] = useState<Set<string>>(new Set()); 703 540 704 - setMessages(prev => [...prev, userMessage]); 705 - setIsSendingMessage(true); 706 - setIsStreaming(true); 541 + const toggleReasoning = (messageId: string) => { 542 + setExpandedReasoning(prev => { 543 + const next = new Set(prev); 544 + if (next.has(messageId)) { 545 + next.delete(messageId); 546 + } else { 547 + next.add(messageId); 548 + } 549 + return next; 550 + }); 551 + }; 707 552 708 - // Reserve space at bottom so streamed content can expand without pushing user to scroll 709 - const vh = Dimensions.get('window').height; 710 - setBottomSpacerHeight(Math.floor(vh * 0.9)); 711 - setHasPositionedForStream(false); 712 - // Clear any previous streaming content when starting a new message 713 - setStreamingMessage(''); 714 - setStreamingStep(''); 715 - // Reset tool accumulation maps at the start of a new stream 716 - toolCallMsgIdsRef.current.clear(); 717 - toolReturnMsgIdsRef.current.clear(); 718 - 719 - const messageToSend = inputText.trim(); 720 - setInputText(''); 553 + // Group messages for efficient FlatList rendering 554 + type MessageGroup = 555 + | { key: string; type: 'toolPair'; call: LettaMessage; ret?: LettaMessage; reasoning?: string } 556 + | { key: string; type: 'message'; message: LettaMessage; reasoning?: string }; 721 557 722 - // Local accumulator to preserve content through callback closures 723 - // Accumulators for streaming assembly with light whitespace coalescing at chunk boundaries 724 - let accumulatedMessage = ''; 725 - let accumulatedStep = ''; 726 - let accumulatedReasoningText = ''; 558 + const groupedMessages = useMemo(() => { 559 + const groups: MessageGroup[] = []; 560 + const toolCallsMap = new Map<string, LettaMessage>(); 561 + const processedIds = new Set<string>(); 562 + 563 + messages.forEach(msg => { 564 + if (msg.message_type?.includes('tool_call') && msg.step_id) { 565 + toolCallsMap.set(msg.step_id, msg); 566 + } 567 + }); 727 568 728 - // Normalize streamed text and coalesce boundary spacing to avoid duplicated spaces 729 - const normalizeStreamText = (s: string) => { 730 - if (!s) return ''; 731 - // Convert escaped sequences to real characters when server/SDK double-escapes 732 - return s 733 - .replace(/\\r\\n/g, '\n') 734 - .replace(/\\n/g, '\n') 735 - .replace(/\\t/g, '\t'); 736 - }; 569 + messages.forEach(msg => { 570 + if (processedIds.has(msg.id)) return; 737 571 738 - // Safely extract text from various SDK/object shapes to avoid "[object Object]" 739 - const extractText = (val: any): string => { 740 - if (val == null) return ''; 741 - if (typeof val === 'string') return val; 742 - if (Array.isArray(val)) return val.map(extractText).join(''); 743 - if (typeof val === 'object') { 744 - // Common fields that may hold text 745 - if (typeof (val as any).text === 'string') return (val as any).text as string; 746 - if (typeof (val as any).content === 'string') return (val as any).content as string; 747 - if (typeof (val as any).message === 'string') return (val as any).message as string; 748 - // Some SDKs wrap parts under choices[0].delta/content 749 - const choices = (val as any).choices; 750 - if (choices && Array.isArray(choices) && choices.length) { 751 - const c = choices[0]; 752 - return extractText(c?.delta?.content ?? c?.message?.content ?? c?.content); 572 + // Filter out login/heartbeat messages 573 + if (msg.role === 'user' && msg.content) { 574 + try { 575 + const parsed = JSON.parse(msg.content); 576 + if (parsed?.type === 'login' || parsed?.type === 'heartbeat') { 577 + processedIds.add(msg.id); 578 + return; 579 + } 580 + } catch { 581 + // Not JSON, keep the message 753 582 } 754 - // Fallback: do not stringify raw objects into [object Object] 755 - return ''; 756 583 } 757 - return ''; 758 - }; 759 584 760 - // Coalesce boundary spacing to avoid duplicates like "word ." or double spaces 761 - const coalesceBoundary = (prev: string, next: string) => { 762 - if (!next) return ''; 763 - let piece = normalizeStreamText(next); 764 - const prevLast = prev.slice(-1); 765 - // If previous ends with whitespace and next begins with whitespace, drop leading whitespace 766 - if (/\s/.test(prevLast) && /^\s/.test(piece)) { 767 - piece = piece.replace(/^\s+/, ''); 768 - } 769 - // If previous ends with space and next begins with punctuation that shouldn't be preceded by a space, drop that leading space 770 - if (prevLast === ' ' && /^\s*[\.,;:!\?\)\]\}]/.test(piece)) { 771 - piece = piece.replace(/^\s+/, ''); 585 + if (msg.message_type?.includes('tool_return') && msg.step_id) { 586 + const toolCall = toolCallsMap.get(msg.step_id); 587 + if (toolCall) { 588 + groups.push({ 589 + key: toolCall.id, 590 + type: 'toolPair', 591 + call: toolCall, 592 + ret: msg, 593 + reasoning: toolCall.reasoning || msg.reasoning, 594 + }); 595 + processedIds.add(toolCall.id); 596 + processedIds.add(msg.id); 597 + return; 598 + } 772 599 } 773 - // If previous ends with an opening bracket and next starts with space, drop the leading space 774 - if (/[\(\[\{]$/.test(prev) && /^\s+/.test(piece)) { 775 - piece = piece.replace(/^\s+/, ''); 600 + 601 + if (!msg.message_type?.includes('tool_call') && !msg.message_type?.includes('tool_return')) { 602 + groups.push({ 603 + key: msg.id, 604 + type: 'message', 605 + message: msg, 606 + reasoning: msg.reasoning, 607 + }); 608 + processedIds.add(msg.id); 776 609 } 777 - return piece; 778 - }; 610 + }); 779 611 780 - try { 781 - await lettaApi.sendMessageStream( 782 - currentAgent.id, 783 - { 784 - messages: [{ role: 'user', content: messageToSend }], 785 - }, 786 - // onChunk callback - handle streaming tokens 787 - (chunk) => { 788 - console.log('Stream chunk:', chunk); 789 - console.log('Chunk keys:', Object.keys(chunk)); 612 + return groups; 613 + }, [messages]); 790 614 791 - const mt = (chunk as any).message_type as string | undefined; 792 - const isCallType = mt === 'tool_call' || mt === 'tool_call_message' || mt === 'tool_message' || (!!(chunk as any).tool_call && !(chunk as any).tool_response); 793 - const isReturnType = mt === 'tool_response' || mt === 'tool_return_message' || (!!(chunk as any).tool_response); 615 + const renderMessageGroup = ({ item }: { item: MessageGroup }) => { 616 + if (item.type === 'toolPair') { 617 + const callText = item.call.content || 'Tool call'; 618 + const resultText = item.ret?.content || undefined; 794 619 795 - if (mt === 'assistant_message' && chunk.content) { 796 - // Append new content with boundary coalescing 797 - let raw = extractText(chunk.content); 798 - // Handle escape sequences split across chunk boundaries, e.g., "\\" + "n" 799 - if (accumulatedMessage.endsWith('\\')) { 800 - if (raw.startsWith('n')) { 801 - accumulatedMessage = accumulatedMessage.slice(0, -1); 802 - raw = '\n' + raw.slice(1); 803 - } else if (raw.startsWith('t')) { 804 - accumulatedMessage = accumulatedMessage.slice(0, -1); 805 - raw = '\t' + raw.slice(1); 806 - } 807 - } 808 - const piece = coalesceBoundary(accumulatedMessage, raw); 809 - accumulatedMessage += piece; 810 - setStreamingMessage(accumulatedMessage); 811 - } else if (chunk.message_type === 'reasoning_message' && chunk.reasoning) { 812 - // Show reasoning/thinking process (accumulate) with boundary coalescing 813 - const piece = coalesceBoundary(accumulatedReasoningText, extractText(chunk.reasoning)); 814 - accumulatedReasoningText += piece; 815 - accumulatedStep = `Thinking: ${accumulatedReasoningText}`; 816 - setStreamingStep(accumulatedStep); 817 - } else if (isCallType) { 818 - // Accumulate tool call text by step instead of creating new entries 819 - const callRaw: any = (chunk as any).tool_call ?? (chunk as any).toolCall ?? {}; 820 - const callObj: any = (callRaw as any).function ? (callRaw as any).function : callRaw; 821 - const name = callObj?.name || callObj?.tool_name || 'tool'; 822 - const args = formatArgsPython(callObj?.arguments ?? callObj?.args ?? {}); 823 - const toolLine = `${name}(${args})`; 824 - const stepId = chunk.step ? String(chunk.step) : 'no-step'; 620 + return ( 621 + <View key={item.key} style={styles.messageContainer}> 622 + <ToolCallItem 623 + callText={callText} 624 + resultText={resultText} 625 + /> 626 + </View> 627 + ); 628 + } else { 629 + const msg = item.message; 630 + const isUser = msg.role === 'user'; 631 + const isSystem = msg.role === 'system'; 825 632 826 - console.log('[UI] tool_call update', { stepId, toolLine }); 827 - setMessages(prev => { 828 - const idFromMap = toolCallMsgIdsRef.current.get(stepId); 829 - if (idFromMap) { 830 - console.log('[UI] updating existing tool_call message', idFromMap); 831 - // Update existing message content for this step 832 - return prev.map(m => m.id === idFromMap ? { ...m, content: toolLine } : m); 833 - } 834 - // Insert a new message and remember its id for subsequent updates 835 - const newId = `toolcall-${stepId}-${Date.now()}`; 836 - toolCallMsgIdsRef.current.set(stepId, newId); 837 - console.log('[UI] inserting new tool_call message', newId); 838 - return [ 839 - ...prev, 840 - { 841 - id: newId, 842 - role: 'tool', 843 - content: toolLine, 844 - created_at: new Date().toISOString(), 845 - message_type: mt || 'tool_message', 846 - step_id: stepId, 847 - reasoning: accumulatedReasoningText ? accumulatedReasoningText : undefined, 848 - } 849 - ]; 850 - }); 851 - } else if (isReturnType) { 852 - // Accumulate tool return text by step 853 - const r = (chunk as any).tool_response ?? (chunk as any).toolReturn ?? (chunk as any).result; 854 - let resultStr = ''; 855 - try { resultStr = typeof r === 'string' ? r : JSON.stringify(r); } catch {} 856 - const stepId = chunk.step ? String(chunk.step) : 'no-step'; 857 - const line = resultStr; 858 - console.log('[UI] tool_return update', { stepId, line }); 859 - setMessages(prev => { 860 - const idFromMap = toolReturnMsgIdsRef.current.get(stepId); 861 - if (idFromMap) { 862 - console.log('[UI] updating existing tool_return message', idFromMap); 863 - return prev.map(m => m.id === idFromMap ? { ...m, content: line } : m); 864 - } 865 - const newId = `toolret-${stepId}-${Date.now()}`; 866 - toolReturnMsgIdsRef.current.set(stepId, newId); 867 - console.log('[UI] inserting new tool_return message', newId); 868 - return [ 869 - ...prev, 870 - { id: newId, role: 'tool', content: line, created_at: new Date().toISOString(), message_type: mt || 'tool_return_message', step_id: stepId } 871 - ]; 872 - }); 873 - } else if (mt === 'approval_request_message') { 874 - const callRaw: any = (chunk as any).tool_call ?? (chunk as any).toolCall ?? {}; 875 - const callObj: any = (callRaw as any).function ? (callRaw as any).function : callRaw; 876 - const name = callObj?.name || callObj?.tool_name || 'tool'; 877 - const args = formatArgsPython(callObj?.arguments ?? callObj?.args ?? {}); 878 - setApprovalData({ id: (chunk as any).id, toolName: name, toolArgs: args, reasoning: (chunk as any).reasoning }); 879 - setApprovalVisible(true); 880 - } 881 - }, 882 - // onComplete callback 883 - (response) => { 884 - console.log('Stream complete:', response); 633 + if (isSystem) return null; 885 634 886 - // Add the completed assistant message to permanent messages if we have content 887 - if (accumulatedMessage.trim()) { 888 - const assistantMessage: LettaMessage = { 889 - id: `assistant-${Date.now()}`, 890 - role: 'assistant', 891 - content: accumulatedMessage.trim(), 892 - created_at: new Date().toISOString(), 893 - reasoning: accumulatedReasoningText ? accumulatedReasoningText : undefined, 894 - }; 635 + if (isUser) { 636 + return ( 637 + <View 638 + key={item.key} 639 + style={[styles.messageContainer, styles.userMessageContainer]} 640 + > 641 + <View 642 + style={[ 643 + styles.messageBubble, 644 + styles.userBubble, 645 + { backgroundColor: colorScheme === 'dark' ? CoColors.pureWhite : CoColors.deepBlack } 646 + ]} 647 + // @ts-ignore - web-only data attribute for CSS targeting 648 + dataSet={{ userMessage: 'true' }} 649 + > 650 + <ExpandableMessageContent 651 + content={msg.content} 652 + isUser={isUser} 653 + isDark={colorScheme === 'dark'} 654 + lineLimit={3} 655 + /> 656 + </View> 657 + </View> 658 + ); 659 + } else { 660 + const isReasoningExpanded = expandedReasoning.has(msg.id); 661 + const isLastMessage = groupedMessages[groupedMessages.length - 1]?.key === item.key; 662 + const shouldHaveMinHeight = isLastMessage && lastMessageNeedsSpace; 895 663 896 - setMessages(prev => [...prev, assistantMessage]); 897 - console.log('Added completed assistant message to chat history'); 898 - } 899 - 900 - // Clear streaming state 901 - setIsStreaming(false); 902 - setStreamingMessage(''); 903 - setStreamingStep(''); 904 - // Reset tool accumulation maps for next message 905 - toolCallMsgIdsRef.current.clear(); 906 - toolReturnMsgIdsRef.current.clear(); 907 - // Smoothly remove reserved space and counter-scroll to avoid jump 908 - smoothRemoveSpacer(240); 909 - }, 910 - // onError callback 911 - (error) => { 912 - console.error('Stream error:', error); 913 - Alert.alert('Error', 'Failed to send message: ' + error.message); 914 - 915 - // Keep the user message visible, just restore input for retry 916 - setInputText(messageToSend); 917 - 918 - // Clear streaming state 919 - setIsStreaming(false); 920 - setStreamingMessage(''); 921 - setStreamingStep(''); 922 - toolCallMsgIdsRef.current.clear(); 923 - toolReturnMsgIdsRef.current.clear(); 924 - // Smoothly remove reserved space and counter-scroll to avoid jump 925 - smoothRemoveSpacer(240); 926 - } 927 - ); 928 - } catch (error: any) { 929 - console.error('Failed to send message:', error); 930 - Alert.alert('Error', 'Failed to send message: ' + error.message); 931 - 932 - // Keep the user message visible, just restore input for retry 933 - setInputText(messageToSend); 934 - 935 - // Clear streaming state 936 - setIsStreaming(false); 937 - setStreamingMessage(''); 938 - setStreamingStep(''); 939 - } finally { 940 - setIsSendingMessage(false); 664 + return ( 665 + <View key={item.key} style={[ 666 + styles.assistantFullWidthContainer, 667 + shouldHaveMinHeight && { minHeight: Math.max(containerHeight * 0.9, 450) } 668 + ]}> 669 + {item.reasoning && ( 670 + <TouchableOpacity 671 + onPress={() => toggleReasoning(msg.id)} 672 + style={styles.reasoningToggle} 673 + > 674 + <Text style={styles.reasoningToggleText}>Reasoning</Text> 675 + <Ionicons 676 + name={isReasoningExpanded ? "chevron-up" : "chevron-down"} 677 + size={16} 678 + color={darkTheme.colors.text.tertiary} 679 + /> 680 + </TouchableOpacity> 681 + )} 682 + {item.reasoning && isReasoningExpanded && ( 683 + <View style={styles.reasoningExpandedContainer}> 684 + <Text style={styles.reasoningExpandedText}>{item.reasoning}</Text> 685 + </View> 686 + )} 687 + <ExpandableMessageContent 688 + content={msg.content} 689 + isUser={isUser} 690 + isDark={colorScheme === 'dark'} 691 + lineLimit={20} 692 + /> 693 + </View> 694 + ); 695 + } 941 696 } 942 697 }; 943 698 944 - const handleAgentCreated = async (agent: LettaAgent) => { 945 - setShowCreateAgentScreen(false); 946 - 947 - // Auto-select the newly created agent and go to chat 948 - await handleAgentSelect(agent); 949 - 950 - Alert.alert('Success', `Agent "${agent.name}" created successfully!`); 699 + const handleScroll = (e: any) => { 700 + const y = e.nativeEvent.contentOffset.y; 701 + setScrollY(y); 702 + const threshold = 80; 703 + const distanceFromBottom = Math.max(0, contentHeight - (y + containerHeight)); 704 + setShowScrollToBottom(distanceFromBottom > threshold); 951 705 }; 952 706 953 - const handleCreateAgentCancel = () => { 954 - setShowCreateAgentScreen(false); 707 + const handleContentSizeChange = (_w: number, h: number) => { 708 + setContentHeight(h); 709 + if (pendingJumpToBottomRef.current && containerHeight > 0 && pendingJumpRetriesRef.current > 0) { 710 + const offset = Math.max(0, h - containerHeight); 711 + scrollViewRef.current?.scrollToOffset({ offset, animated: false }); 712 + setShowScrollToBottom(false); 713 + pendingJumpRetriesRef.current -= 1; 714 + if (pendingJumpRetriesRef.current <= 0) pendingJumpToBottomRef.current = false; 715 + } 955 716 }; 956 717 957 - const handleLogout = async () => { 958 - try { 959 - await Storage.removeItem(STORAGE_KEYS.TOKEN); 960 - await Storage.removeItem(STORAGE_KEYS.AGENT_ID); 961 - await Storage.removeItem(STORAGE_KEYS.PROJECT_ID); 962 - lettaApi.removeAuthToken(); 963 - setApiToken(''); 964 - setIsConnected(false); 965 - setCurrentProject(null); 966 - setCurrentAgent(null); 967 - setAgents([]); 968 - setMessages([]); 969 - setShowChatView(false); 970 - setShowAgentSelector(true); 971 - console.log('Logged out successfully'); 972 - } catch (error) { 973 - console.error('Error during logout:', error); 718 + const handleMessagesLayout = (e: any) => { 719 + const h = e.nativeEvent.layout.height; 720 + setContainerHeight(h); 721 + if (pendingJumpToBottomRef.current && contentHeight > 0 && pendingJumpRetriesRef.current > 0) { 722 + const offset = Math.max(0, contentHeight - h); 723 + scrollViewRef.current?.scrollToOffset({ offset, animated: false }); 724 + setShowScrollToBottom(false); 725 + pendingJumpRetriesRef.current -= 1; 726 + if (pendingJumpRetriesRef.current <= 0) pendingJumpToBottomRef.current = false; 974 727 } 975 728 }; 976 729 977 - if (isLoadingToken) { 730 + const scrollToBottom = () => { 731 + scrollViewRef.current?.scrollToEnd({ animated: true }); 732 + setShowScrollToBottom(false); 733 + }; 734 + 735 + const handleInputLayout = (e: any) => { 736 + setInputContainerHeight(e.nativeEvent.layout.height || 0); 737 + }; 738 + 739 + const inputStyles = { 740 + flex: 1, 741 + maxHeight: 100, 742 + height: 48, 743 + paddingLeft: 18, 744 + paddingRight: 54, 745 + paddingTop: 14, 746 + paddingBottom: 14, 747 + borderRadius: 28, 748 + color: colorScheme === 'dark' ? '#000000' : '#FFFFFF', // Inverted: black text in dark mode 749 + fontFamily: 'Lexend_400Regular', 750 + fontSize: 16, 751 + lineHeight: 20, 752 + borderWidth: 0, 753 + backgroundColor: 'transparent', 754 + ...(Platform.OS === 'web' && { 755 + // @ts-ignore 756 + background: 'transparent', 757 + backgroundImage: 'none', 758 + WebkitAppearance: 'none', 759 + MozAppearance: 'none', 760 + }), 761 + } as const; 762 + 763 + if (isLoadingToken || !fontsLoaded) { 978 764 return ( 979 - <View style={styles.container}> 980 - <View style={[styles.setupContainer, { paddingTop: insets.top }]}> 981 - <Wordmark width={320} height={60} /> 982 - <Text style={styles.subtitle}>Loading...</Text> 983 - </View> 984 - <StatusBar style="auto" /> 985 - </View> 765 + <SafeAreaView style={[styles.loadingContainer, { backgroundColor: theme.colors.background.primary }]}> 766 + <ActivityIndicator size="large" color={theme.colors.interactive.primary} /> 767 + <StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} /> 768 + </SafeAreaView> 986 769 ); 987 770 } 988 771 989 772 if (!isConnected) { 990 773 return ( 991 - <View style={styles.container}> 992 - <View style={[styles.setupContainer, { paddingTop: insets.top }]}> 993 - <Wordmark width={320} height={60} /> 994 - <Text style={styles.subtitle}>Enter your Letta API token to get started</Text> 995 - 996 - <TextInput 997 - style={styles.input} 998 - placeholder="Letta API Token" 999 - value={apiToken} 1000 - onChangeText={setApiToken} 1001 - secureTextEntry 1002 - /> 1003 - 1004 - <TouchableOpacity 1005 - style={[styles.button, isConnecting && styles.buttonDisabled]} 1006 - onPress={handleConnect} 1007 - disabled={isConnecting} 1008 - > 1009 - {isConnecting ? ( 1010 - <ActivityIndicator color="#fff" /> 1011 - ) : ( 1012 - <Text style={styles.buttonText}>Connect</Text> 1013 - )} 1014 - </TouchableOpacity> 1015 - 1016 - <Text style={styles.instructions}> 1017 - Get an API key from{' '} 1018 - <Text 1019 - style={styles.link} 1020 - onPress={() => Linking.openURL('https://app.letta.com/api-keys')} 1021 - > 1022 - https://app.letta.com/api-keys 1023 - </Text> 1024 - </Text> 1025 - </View> 1026 - <StatusBar style="auto" /> 1027 - </View> 1028 - ); 1029 - } 1030 - 1031 - if (showCreateAgentScreen) { 1032 - return ( 1033 - <CreateAgentScreen 1034 - onAgentCreated={handleAgentCreated} 1035 - onCancel={handleCreateAgentCancel} 774 + <CoLoginScreen 775 + onLogin={handleLogin} 776 + isLoading={isConnecting} 777 + error={connectionError} 1036 778 /> 1037 779 ); 1038 780 } 1039 781 1040 - // Show agent selector after login (main screen) - only when we have a project 1041 - if (showAgentSelector && !showChatView) { 782 + if (isInitializingCo || !coAgent) { 1042 783 return ( 1043 - <> 1044 - {currentProject ? ( 1045 - <AgentSelectorScreen 1046 - currentProject={currentProject} 1047 - onAgentSelect={handleAgentSelect} 1048 - onProjectPress={() => setShowProjectSelector(true)} 1049 - onCreateAgent={() => setShowCreateAgentScreen(true)} 1050 - onLogout={handleLogout} 1051 - /> 1052 - ) : null} 1053 - 1054 - <ProjectSelectorModal 1055 - visible={showProjectSelector || !currentProject} 1056 - currentProject={currentProject} 1057 - onProjectSelect={handleProjectSelect} 1058 - onClose={() => setShowProjectSelector(false)} 1059 - /> 1060 - </> 784 + <SafeAreaView style={[styles.loadingContainer, { backgroundColor: theme.colors.background.primary }]}> 785 + <ActivityIndicator size="large" color={theme.colors.interactive.primary} /> 786 + <Text style={[styles.loadingText, { color: theme.colors.text.secondary }]}>Initializing co...</Text> 787 + <StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} /> 788 + </SafeAreaView> 1061 789 ); 1062 790 } 1063 791 1064 - // Show chat view when agent is selected 792 + // Main chat view 1065 793 return ( 1066 - <View style={styles.container}> 1067 - <View style={[styles.mainLayout, isDesktop && styles.desktopLayout]}> 1068 - {/* Sidebar for desktop */} 1069 - {isDesktop && ( 1070 - <Sidebar 1071 - currentProject={currentProject} 1072 - currentAgent={currentAgent} 1073 - onAgentSelect={handleAgentSelect} 1074 - onProjectPress={() => setShowProjectSelector(true)} 1075 - onCreateAgent={() => setShowCreateAgentScreen(true)} 1076 - onLogout={handleLogout} 1077 - isVisible={true} 1078 - onTabChange={(tab) => { 1079 - setActiveSidebarTab(tab); 1080 - if (tab !== 'memory') { 1081 - setSelectedBlock(null); 1082 - } 1083 - }} 794 + <View 795 + style={[styles.container, { backgroundColor: theme.colors.background.primary }]} 796 + // @ts-ignore - web-only data attribute 797 + dataSet={{ theme: colorScheme }} 798 + > 799 + {/* Header */} 800 + <View style={[styles.header, { paddingTop: insets.top, backgroundColor: theme.colors.background.secondary, borderBottomColor: theme.colors.border.primary }]}> 801 + <TouchableOpacity onPress={() => setSidebarVisible(true)} style={styles.menuButton}> 802 + <Ionicons name="menu" size={24} color={theme.colors.text.primary} /> 803 + </TouchableOpacity> 804 + 805 + <View style={styles.headerCenter}> 806 + <Text style={[styles.headerTitle, { color: theme.colors.text.primary }]}>co</Text> 807 + </View> 808 + 809 + <TouchableOpacity 810 + onPress={toggleColorScheme} 811 + style={styles.headerButton} 812 + > 813 + <Ionicons 814 + name={colorScheme === 'dark' ? 'sunny-outline' : 'moon-outline'} 815 + size={24} 816 + color={theme.colors.text.primary} 1084 817 /> 1085 - )} 818 + </TouchableOpacity> 819 + 820 + <TouchableOpacity onPress={handleLogout} style={styles.logoutButton}> 821 + <Ionicons name="log-out-outline" size={24} color={theme.colors.text.primary} /> 822 + </TouchableOpacity> 823 + </View> 1086 824 1087 - {/* Mobile sidebar modal */} 1088 - {!isDesktop && ( 1089 - <Modal 1090 - visible={sidebarVisible} 1091 - animationType="slide" 1092 - presentationStyle="overFullScreen" 1093 - onRequestClose={() => setSidebarVisible(false)} 1094 - > 1095 - <SafeAreaView style={styles.mobileModal}> 1096 - <View style={styles.modalHeader}> 1097 - <TouchableOpacity onPress={() => setSidebarVisible(false)}> 1098 - <Text style={styles.modalCloseText}>✕</Text> 1099 - </TouchableOpacity> 825 + {/* Messages */} 826 + <View style={styles.messagesContainer} onLayout={handleMessagesLayout}> 827 + <FlatList 828 + ref={scrollViewRef} 829 + data={groupedMessages} 830 + renderItem={renderMessageGroup} 831 + keyExtractor={(item) => item.key} 832 + onScroll={handleScroll} 833 + onContentSizeChange={handleContentSizeChange} 834 + contentContainerStyle={styles.messagesList} 835 + ListHeaderComponent={ 836 + hasMoreBefore ? ( 837 + <TouchableOpacity onPress={loadMoreMessages} style={styles.loadMoreButton}> 838 + {isLoadingMore ? ( 839 + <ActivityIndicator size="small" color={theme.colors.text.secondary} /> 840 + ) : ( 841 + <Text style={styles.loadMoreText}>Load more messages</Text> 842 + )} 843 + </TouchableOpacity> 844 + ) : null 845 + } 846 + ListFooterComponent={ 847 + <> 848 + {isStreaming && ( 849 + <Animated.View style={[styles.assistantFullWidthContainer, { minHeight: spacerHeightAnim }]}> 850 + {streamingStep && ( 851 + <Text style={styles.streamingStep}>{streamingStep}</Text> 852 + )} 853 + {streamingMessage && ( 854 + <MessageContent 855 + content={streamingMessage + ' ○'} 856 + isUser={false} 857 + isDark={colorScheme === 'dark'} 858 + /> 859 + )} 860 + </Animated.View> 861 + )} 862 + </> 863 + } 864 + ListEmptyComponent={ 865 + isLoadingMessages ? ( 866 + <View style={styles.emptyContainer}> 867 + <ActivityIndicator size="large" color={theme.colors.text.secondary} /> 1100 868 </View> 1101 - <Sidebar 1102 - currentProject={currentProject} 1103 - currentAgent={currentAgent} 1104 - onAgentSelect={(agent) => { 1105 - handleAgentSelect(agent); 1106 - setSidebarVisible(false); 1107 - }} 1108 - onProjectPress={() => { 1109 - setSidebarVisible(false); 1110 - setShowProjectSelector(true); 1111 - }} 1112 - onCreateAgent={() => { 1113 - setSidebarVisible(false); 1114 - setShowCreateAgentScreen(true); 1115 - }} 1116 - onLogout={handleLogout} 1117 - isVisible={true} 1118 - onTabChange={(tab) => { 1119 - setActiveSidebarTab(tab); 1120 - if (tab !== 'memory') { 1121 - setSelectedBlock(null); 1122 - } 1123 - }} 1124 - /> 1125 - </SafeAreaView> 1126 - </Modal> 869 + ) : ( 870 + <View style={styles.emptyContainer}> 871 + <Text style={styles.emptyText}>Start your conversation with Co</Text> 872 + </View> 873 + ) 874 + } 875 + /> 876 + 877 + {/* Scroll to bottom button */} 878 + {showScrollToBottom && ( 879 + <TouchableOpacity onPress={scrollToBottom} style={styles.scrollToBottomButton}> 880 + <Ionicons name="arrow-down" size={24} color="#000" /> 881 + </TouchableOpacity> 1127 882 )} 883 + </View> 1128 884 1129 - {/* Chat Area */} 1130 - <View style={styles.chatArea}> 1131 - {/* Header */} 885 + {/* Input */} 886 + <View style={[styles.inputContainer, { paddingBottom: Math.max(insets.bottom, 16) }]} onLayout={handleInputLayout}> 887 + <View style={styles.inputCentered}> 888 + {/* Solid backdrop matching theme */} 1132 889 <View style={[ 1133 - styles.chatHeader, 890 + styles.inputBackdrop, 1134 891 { 1135 - paddingTop: Platform.OS === 'android' ? insets.top : 0, 1136 - height: darkTheme.layout.headerHeight + (Platform.OS === 'android' ? insets.top : 0) 892 + backgroundColor: colorScheme === 'dark' ? '#FFFFFF' : '#000000', 893 + borderWidth: 0, 1137 894 } 1138 - ]}> 1139 - {!isDesktop && ( 1140 - <TouchableOpacity 1141 - style={styles.menuButton} 1142 - onPress={() => setSidebarVisible(true)} 1143 - > 1144 - <Text style={styles.menuIcon}>☰</Text> 1145 - </TouchableOpacity> 1146 - )} 1147 - {activeSidebarTab === 'memory' && selectedBlock && ( 1148 - <TouchableOpacity 1149 - style={styles.menuButton} 1150 - onPress={() => setSelectedBlock(null)} 1151 - accessibilityLabel="Back to memory list" 1152 - > 1153 - <Ionicons name="chevron-back" size={18} color={darkTheme.colors.text.secondary} /> 1154 - </TouchableOpacity> 1155 - )} 1156 - <View style={styles.headerContent}> 1157 - {activeSidebarTab === 'memory' ? ( 1158 - <> 1159 - <Text style={styles.agentTitle}> 1160 - {selectedBlock ? (selectedBlock.name || selectedBlock.label || 'Memory') : 'Memory Blocks'} 1161 - </Text> 1162 - {currentAgent && ( 1163 - <Text style={styles.agentSubtitle}> 1164 - {currentAgent.name} 1165 - </Text> 1166 - )} 1167 - </> 895 + ]} /> 896 + <View style={styles.inputWrapper}> 897 + <TextInput 898 + style={inputStyles} 899 + placeholder="" 900 + placeholderTextColor={colorScheme === 'dark' ? '#666666' : '#999999'} 901 + value={inputText} 902 + onChangeText={setInputText} 903 + multiline 904 + maxLength={4000} 905 + editable={!isSendingMessage} 906 + /> 907 + <TouchableOpacity 908 + onPress={sendMessage} 909 + style={[ 910 + styles.sendButton, 911 + { backgroundColor: colorScheme === 'dark' ? CoColors.deepBlack : CoColors.pureWhite }, 912 + (!inputText.trim() || isSendingMessage) && styles.sendButtonDisabled 913 + ]} 914 + disabled={!inputText.trim() || isSendingMessage} 915 + > 916 + {isSendingMessage ? ( 917 + <ActivityIndicator size="small" color={colorScheme === 'dark' ? '#fff' : '#000'} /> 1168 918 ) : ( 1169 - <> 1170 - <Text style={styles.agentTitle}> 1171 - {currentAgent ? currentAgent.name : 'Select an agent'} 1172 - </Text> 1173 - {currentAgent && ( 1174 - <Text style={styles.agentSubtitle}> 1175 - {agentProjectName || currentProject?.name || ''} 1176 - </Text> 1177 - )} 1178 - </> 919 + <View style={[styles.sendRing, { borderColor: colorScheme === 'dark' ? CoColors.pureWhite : CoColors.deepBlack }]} /> 1179 920 )} 1180 - </View> 1181 - {/* Old single-pill toggle removed in favor of segmented control */} 1182 - {currentAgent && ( 1183 - <View style={styles.segmentedToggle} accessibilityRole="tablist"> 1184 - <TouchableOpacity 1185 - style={[styles.segmentButton, activeSidebarTab === 'memory' && styles.segmentButtonActive]} 1186 - onPress={() => setActiveSidebarTab('memory')} 1187 - accessibilityRole="tab" 1188 - accessibilityState={{ selected: activeSidebarTab === 'memory' }} 1189 - accessibilityLabel="Memory" 1190 - > 1191 - <Text style={[styles.segmentText, activeSidebarTab === 'memory' && styles.segmentTextActive]}>Memory</Text> 1192 - </TouchableOpacity> 1193 - <TouchableOpacity 1194 - style={[styles.segmentButton, styles.segmentRight, activeSidebarTab !== 'memory' && styles.segmentButtonActive]} 1195 - onPress={() => setActiveSidebarTab('project')} 1196 - accessibilityRole="tab" 1197 - accessibilityState={{ selected: activeSidebarTab !== 'memory' }} 1198 - accessibilityLabel="Chat" 1199 - > 1200 - <Text style={[styles.segmentText, activeSidebarTab !== 'memory' && styles.segmentTextActive]}>Chat</Text> 1201 - </TouchableOpacity> 1202 - </View> 1203 - )} 1204 - {currentAgent && ( 1205 - <TouchableOpacity 1206 - onPress={() => currentAgent && toggleFavorite(currentAgent.id)} 1207 - style={styles.headerIconButton} 1208 - accessibilityLabel={isFavorite(currentAgent.id) ? "Unfavorite agent" : "Favorite agent"} 1209 - > 1210 - <Ionicons 1211 - name={isFavorite(currentAgent.id) ? 'star' : 'star-outline'} 1212 - size={18} 1213 - color={isFavorite(currentAgent.id) ? '#ffd166' : darkTheme.colors.text.secondary} 1214 - /> 1215 - </TouchableOpacity> 1216 - )} 1217 - {currentAgent && ( 1218 - <TouchableOpacity 1219 - onPress={handleOpenAgentInDashboard} 1220 - style={styles.headerIconButton} 1221 - accessibilityLabel="Open in Letta agent editor" 1222 - > 1223 - <Ionicons name="open-outline" size={18} color={darkTheme.colors.text.secondary} /> 1224 - </TouchableOpacity> 1225 - )} 921 + </TouchableOpacity> 1226 922 </View> 923 + </View> 924 + </View> 1227 925 1228 - {/* Memory view or Messages */} 1229 - {activeSidebarTab === 'memory' ? ( 1230 - selectedBlock ? ( 1231 - <ScrollView style={styles.messagesContainer}> 1232 - <View style={styles.messagesList}> 1233 - <MessageContent content={selectedBlock.value || ''} isUser={false} /> 1234 - </View> 1235 - </ScrollView> 1236 - ) : isLoadingBlocks ? ( 1237 - <View style={styles.loadingContainer}> 1238 - <LogoLoader 1239 - source={colorScheme === 'dark' 1240 - ? require('./assets/animations/Dark-sygnetrotate2.json') 1241 - : require('./assets/animations/Light-sygnetrotate2.json')} 1242 - size={120} 1243 - /> 1244 - <Text style={styles.loadingText}>Loading memory blocks...</Text> 1245 - </View> 926 + {/* Sidebar */} 927 + <Modal 928 + visible={sidebarVisible} 929 + animationType="slide" 930 + transparent={true} 931 + onRequestClose={() => setSidebarVisible(false)} 932 + > 933 + <TouchableOpacity 934 + style={styles.modalOverlay} 935 + activeOpacity={1} 936 + onPress={() => setSidebarVisible(false)} 937 + > 938 + <View style={[styles.sidebarContainer, { paddingTop: insets.top }]}> 939 + <TouchableOpacity onPress={() => setSidebarVisible(false)} style={styles.closeSidebar}> 940 + <Ionicons name="close" size={24} color={theme.colors.text.primary} /> 941 + </TouchableOpacity> 942 + 943 + <Text style={styles.sidebarTitle}>co Memory</Text> 944 + 945 + {isLoadingBlocks ? ( 946 + <ActivityIndicator size="large" color={darkTheme.colors.text.secondary} /> 1246 947 ) : blocksError ? ( 1247 - <View style={styles.loadingContainer}> 1248 - <Text style={styles.errorText}>{blocksError}</Text> 1249 - </View> 948 + <Text style={styles.errorText}>{blocksError}</Text> 1250 949 ) : ( 1251 - <ScrollView style={styles.messagesContainer}> 1252 - <View style={styles.messagesList}> 1253 - {memoryBlocks.length === 0 ? ( 1254 - <View style={styles.emptyContainer}> 1255 - <Text style={styles.emptyText}>No memory blocks</Text> 1256 - </View> 1257 - ) : ( 1258 - memoryBlocks.map((b) => { 1259 - const derivedName = (b.name && b.name.trim()) 1260 - ? b.name.trim() 1261 - : (b.label ? b.label.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) : 'Untitled') 1262 - return ( 1263 - <TouchableOpacity 1264 - key={b.id} 1265 - style={styles.blockItem} 1266 - onPress={() => setSelectedBlock(b)} 1267 - > 1268 - {!!b.label && ( 1269 - <Text numberOfLines={1} style={styles.blockLabel}>{b.label}</Text> 1270 - )} 1271 - <Text numberOfLines={1} style={styles.blockName}>{derivedName}</Text> 1272 - {!!b.description && ( 1273 - <Text numberOfLines={2} style={styles.blockDesc}>{b.description}</Text> 1274 - )} 1275 - </TouchableOpacity> 1276 - ) 1277 - }) 1278 - )} 1279 - </View> 1280 - </ScrollView> 1281 - ) 1282 - ) : isLoadingMessages ? ( 1283 - <View style={styles.loadingContainer}> 1284 - <LogoLoader 1285 - source={colorScheme === 'dark' 1286 - ? require('./assets/animations/Dark-sygnetrotate2.json') 1287 - : require('./assets/animations/Light-sygnetrotate2.json')} 1288 - size={120} 1289 - /> 1290 - <Text style={styles.loadingText}>Loading messages...</Text> 1291 - </View> 1292 - ) : ( 1293 - <> 1294 950 <FlatList 1295 - ref={scrollViewRef} 1296 - style={styles.messagesContainer} 1297 - contentContainerStyle={[styles.messagesList, { paddingBottom: bottomSpacerHeight }]} 1298 - data={messageGroups} 1299 - keyExtractor={(item) => item.key} 1300 - renderItem={renderGroupItem} 1301 - onScroll={handleScroll} 1302 - onContentSizeChange={handleContentSizeChange} 1303 - onLayout={handleMessagesLayout} 1304 - scrollEventThrottle={16} 1305 - maintainVisibleContentPosition={{ minIndexForVisible: 0, autoscrollToTopThreshold: 10 }} 1306 - ListHeaderComponent={hasMoreBefore ? ( 1307 - <TouchableOpacity style={styles.loadMoreBtn} onPress={loadOlderMessages} disabled={isLoadingMore}> 1308 - <Text style={styles.loadMoreText}>{isLoadingMore ? 'Loading…' : 'Load earlier messages'}</Text> 1309 - </TouchableOpacity> 1310 - ) : null} 1311 - ListEmptyComponent={currentAgent ? ( 1312 - <View style={styles.emptyContainer}> 1313 - <Text style={styles.emptyText}>Start a conversation with {currentAgent.name}</Text> 1314 - </View> 1315 - ) : null} 1316 - ListFooterComponent={ 1317 - <> 1318 - {isStreaming && ( 1319 - <View 1320 - style={styles.messageGroup} 1321 - onLayout={(e) => { 1322 - if (!hasPositionedForStream) { 1323 - const y = e.nativeEvent.layout.y; 1324 - scrollViewRef.current?.scrollToOffset({ offset: Math.max(0, y - 8), animated: false }); 1325 - setHasPositionedForStream(true); 1326 - } 1327 - }} 1328 - > 1329 - {streamingStep && streamingStep.trim().length > 0 && ( 1330 - <View style={styles.reasoningContainer}> 1331 - <Text style={styles.reasoningLabel}>Reasoning</Text> 1332 - <Text style={styles.reasoningText}>{streamingStep.trim()}</Text> 1333 - </View> 1334 - )} 1335 - <View style={[styles.message, styles.assistantMessage, styles.streamingMessage]}> 1336 - {streamingMessage && String(streamingMessage).trim().length > 0 ? ( 1337 - <> 1338 - <MessageContent content={String(streamingMessage).trim()} isUser={false} /> 1339 - <Text style={styles.cursor}>|</Text> 1340 - </> 1341 - ) : ( 1342 - <View style={styles.thinkingIndicator}> 1343 - <ActivityIndicator size="small" color="#666" /> 1344 - <Text style={styles.thinkingText}>Agent is thinking...</Text> 1345 - </View> 1346 - )} 1347 - </View> 1348 - </View> 1349 - )} 1350 - 1351 - {isSendingMessage && !isStreaming && ( 1352 - <View style={styles.messageGroup}> 1353 - <View style={[styles.message, styles.assistantMessage]}> 1354 - <ActivityIndicator size="small" color="#666" /> 1355 - </View> 1356 - </View> 1357 - )} 1358 - 1359 - {approvalVisible && ( 1360 - <View style={styles.messageGroup}> 1361 - <View style={[styles.message, styles.assistantMessage]}> 1362 - <View style={styles.approvalCardInline}> 1363 - <Text style={styles.approvalTitle}>Approval Required</Text> 1364 - {!!approvalData?.reasoning && ( 1365 - <View style={styles.reasoningContainer}> 1366 - <Text style={styles.reasoningLabel}>Agent's Reasoning</Text> 1367 - <Text style={styles.reasoningText}>{approvalData?.reasoning}</Text> 1368 - </View> 1369 - )} 1370 - <View style={styles.approvalBlock}> 1371 - <Text style={styles.approvalLabel}>Proposed Tool</Text> 1372 - <Text style={styles.approvalCode}>{approvalData?.toolName}({approvalData?.toolArgs})</Text> 1373 - </View> 1374 - <View style={styles.approvalButtons}> 1375 - <TouchableOpacity 1376 - style={[styles.approvalBtn, styles.approve]} 1377 - disabled={isApproving} 1378 - onPress={async () => { 1379 - if (!currentAgent || !approvalData?.id) return; 1380 - try { 1381 - setIsApproving(true); 1382 - await lettaApi.approveToolRequestStream( 1383 - currentAgent.id, 1384 - { approval_request_id: approvalData.id, approve: true }, 1385 - undefined, 1386 - async () => { 1387 - setApprovalVisible(false); 1388 - setApprovalData(null); 1389 - setApprovalReason(''); 1390 - await loadMessagesForAgent(currentAgent.id); 1391 - }, 1392 - (err) => { 1393 - Alert.alert('Error', err.message || 'Failed to approve'); 1394 - } 1395 - ); 1396 - } finally { 1397 - setIsApproving(false); 1398 - } 1399 - }} 1400 - > 1401 - <Text style={styles.approvalBtnText}>Approve</Text> 1402 - </TouchableOpacity> 1403 - <TouchableOpacity 1404 - style={[styles.approvalBtn, styles.reject]} 1405 - disabled={isApproving} 1406 - onPress={async () => { 1407 - if (!currentAgent || !approvalData?.id) return; 1408 - try { 1409 - setIsApproving(true); 1410 - await lettaApi.approveToolRequestStream( 1411 - currentAgent.id, 1412 - { approval_request_id: approvalData.id, approve: false }, 1413 - undefined, 1414 - async () => { 1415 - setApprovalVisible(false); 1416 - setApprovalData(null); 1417 - setApprovalReason(''); 1418 - await loadMessagesForAgent(currentAgent.id); 1419 - }, 1420 - (err) => { 1421 - Alert.alert('Error', err.message || 'Failed to deny'); 1422 - } 1423 - ); 1424 - } finally { 1425 - setIsApproving(false); 1426 - } 1427 - }} 1428 - > 1429 - <Text style={styles.approvalBtnText}>Reject</Text> 1430 - </TouchableOpacity> 1431 - </View> 1432 - <View style={styles.feedbackBlock}> 1433 - <Text style={styles.approvalLabel}>Reject with feedback</Text> 1434 - <TextInput 1435 - style={styles.approvalInput} 1436 - placeholder="Provide guidance for the agent" 1437 - placeholderTextColor={darkTheme.colors.text.secondary} 1438 - value={approvalReason} 1439 - onChangeText={setApprovalReason} 1440 - multiline 1441 - /> 1442 - <TouchableOpacity 1443 - style={[styles.approvalBtn, styles.reject]} 1444 - disabled={isApproving || approvalReason.trim().length === 0} 1445 - onPress={async () => { 1446 - if (!currentAgent || !approvalData?.id) return; 1447 - try { 1448 - setIsApproving(true); 1449 - await lettaApi.approveToolRequestStream( 1450 - currentAgent.id, 1451 - { approval_request_id: approvalData.id, approve: false, reason: approvalReason.trim() }, 1452 - undefined, 1453 - async () => { 1454 - setApprovalVisible(false); 1455 - setApprovalData(null); 1456 - setApprovalReason(''); 1457 - await loadMessagesForAgent(currentAgent.id); 1458 - }, 1459 - (err) => { 1460 - Alert.alert('Error', err.message || 'Failed to deny with feedback'); 1461 - } 1462 - ); 1463 - } finally { 1464 - setIsApproving(false); 1465 - } 1466 - }} 1467 - > 1468 - <Text style={styles.approvalBtnText}>Send Feedback</Text> 1469 - </TouchableOpacity> 1470 - </View> 1471 - </View> 1472 - </View> 1473 - </View> 1474 - )} 1475 - </> 1476 - } 1477 - /> 1478 - {false && ( 1479 - <ScrollView 1480 - ref={scrollViewRef} 1481 - style={styles.messagesContainer} 1482 - contentContainerStyle={{ paddingBottom: bottomSpacerHeight }} 1483 - onScroll={handleScroll} 1484 - onContentSizeChange={handleContentSizeChange} 1485 - onLayout={handleMessagesLayout} 1486 - scrollEventThrottle={16}> 1487 - <View style={styles.messagesList}> 1488 - {hasMoreBefore && ( 1489 - <TouchableOpacity style={styles.loadMoreBtn} onPress={loadOlderMessages} disabled={isLoadingMore}> 1490 - <Text style={styles.loadMoreText}>{isLoadingMore ? 'Loading…' : 'Load earlier messages'}</Text> 951 + data={memoryBlocks} 952 + keyExtractor={(item) => item.id || item.label} 953 + renderItem={({ item }) => ( 954 + <TouchableOpacity 955 + style={styles.memoryBlockItem} 956 + onPress={() => setSelectedBlock(item)} 957 + > 958 + <Text style={styles.memoryBlockLabel}>{item.label}</Text> 959 + <Text style={styles.memoryBlockPreview} numberOfLines={2}> 960 + {item.value} 961 + </Text> 1491 962 </TouchableOpacity> 1492 963 )} 1493 - {messages.length === 0 && currentAgent && ( 1494 - <View style={styles.emptyContainer}> 1495 - <Text style={styles.emptyText}> 1496 - Start a conversation with {currentAgent.name} 1497 - </Text> 1498 - </View> 1499 - )} 1500 - {(() => { 1501 - const items: any[] = []; 1502 - const callTypes = new Set(['tool_call', 'tool_call_message', 'tool_message']); 1503 - const retTypes = new Set(['tool_response', 'tool_return_message']); 1504 - // Track which step_id has already shown reasoning to avoid duplicates 1505 - const shownReasoningForStep = new Set<string>(); 1506 - 1507 - // Helper: find reasoning text for a given step by scanning ahead 1508 - const findReasoningForStep = (fromIndex: number, stepId?: string): string | undefined => { 1509 - if (!stepId) return undefined; 1510 - for (let j = fromIndex; j < messages.length; j++) { 1511 - const msgAny: any = messages[j]; 1512 - if (msgAny.role === 'assistant' && msgAny.step_id && String(msgAny.step_id) === String(stepId) && msgAny.reasoning) { 1513 - return String(msgAny.reasoning); 1514 - } 1515 - } 1516 - return undefined; 1517 - }; 1518 - 1519 - for (let i = 0; i < messages.length; i++) { 1520 - const m: any = messages[i]; 964 + /> 965 + )} 966 + </View> 967 + </TouchableOpacity> 968 + </Modal> 1521 969 1522 - if (m.role === 'tool' && callTypes.has(m.message_type)) { 1523 - const next: any = messages[i + 1]; 1524 - const paired = !!(next && next.role === 'tool' && retTypes.has(next.message_type) && ( 1525 - (next.step_id && m.step_id && String(next.step_id) === String(m.step_id)) || 1526 - (!m.step_id || m.step_id === 'no-step') 1527 - )); 970 + {/* Memory block detail modal */} 971 + <Modal 972 + visible={selectedBlock !== null} 973 + animationType="slide" 974 + transparent={true} 975 + onRequestClose={() => setSelectedBlock(null)} 976 + > 977 + <View style={styles.modalOverlay}> 978 + <View style={[styles.detailContainer, { paddingTop: insets.top }]}> 979 + <View style={styles.detailHeader}> 980 + <Text style={styles.detailTitle}>{selectedBlock?.label}</Text> 981 + <TouchableOpacity onPress={() => setSelectedBlock(null)}> 982 + <Ionicons name="close" size={24} color={theme.colors.text.primary} /> 983 + </TouchableOpacity> 984 + </View> 985 + <Text style={styles.detailContent}>{selectedBlock?.value}</Text> 986 + </View> 987 + </View> 988 + </Modal> 1528 989 1529 - // Resolve reasoning to show above this tool-call group 1530 - let reasoningToShow: string | undefined = m.reasoning; 1531 - if (!reasoningToShow && m.step_id && !shownReasoningForStep.has(m.step_id)) { 1532 - reasoningToShow = findReasoningForStep(i + 1, m.step_id); 1533 - } 990 + {/* Approval modal */} 991 + <Modal 992 + visible={approvalVisible} 993 + animationType="fade" 994 + transparent={true} 995 + onRequestClose={() => setApprovalVisible(false)} 996 + > 997 + <View style={styles.approvalOverlay}> 998 + <View style={styles.approvalContainer}> 999 + <Text style={styles.approvalTitle}>Tool Approval Required</Text> 1534 1000 1535 - items.push( 1536 - <View key={`${m.id}-grp-${i}`} style={styles.messageGroup}> 1537 - {reasoningToShow && ( 1538 - <View style={styles.reasoningContainer}> 1539 - <Text style={styles.reasoningLabel}>Reasoning</Text> 1540 - <Text style={styles.reasoningText}>{reasoningToShow}</Text> 1541 - </View> 1542 - )} 1543 - <View style={[styles.message, styles.assistantMessage]}> 1544 - <ToolCallItem callText={m.content} resultText={paired ? next.content : undefined} /> 1545 - </View> 1546 - </View> 1547 - ); 1001 + {approvalData?.toolName && ( 1002 + <Text style={styles.approvalTool}>Tool: {approvalData.toolName}</Text> 1003 + )} 1548 1004 1549 - if (m.step_id && reasoningToShow) { 1550 - shownReasoningForStep.add(m.step_id); 1551 - } 1552 - if (paired) { i++; } 1553 - continue; 1554 - } 1005 + {approvalData?.reasoning && ( 1006 + <View style={styles.approvalReasoning}> 1007 + <Text style={styles.approvalReasoningLabel}>Reasoning:</Text> 1008 + <Text style={styles.approvalReasoningText}>{approvalData.reasoning}</Text> 1009 + </View> 1010 + )} 1555 1011 1556 - // Default rendering for non-tool or unpaired tool messages 1557 - items.push( 1558 - <View key={`${m.id || 'msg'}-${i}-${m.created_at}`} style={styles.messageGroup}> 1559 - {m.role === 'assistant' && (m as any).reasoning && (!m.step_id || !shownReasoningForStep.has(m.step_id)) && ( 1560 - <View style={styles.reasoningContainer}> 1561 - <Text style={styles.reasoningLabel}>Reasoning</Text> 1562 - <Text style={styles.reasoningText}>{(m as any).reasoning}</Text> 1563 - </View> 1564 - )} 1565 - <View style={[styles.message, m.role === 'user' ? styles.userMessage : styles.assistantMessage]}> 1566 - {m.role === 'tool' ? ( 1567 - <Text style={styles.messageText}>{m.content}</Text> 1568 - ) : ( 1569 - <MessageContent content={m.content} isUser={m.role === 'user'} /> 1570 - )} 1571 - </View> 1572 - </View> 1573 - ); 1574 - 1575 - if (m.role === 'assistant' && (m as any).reasoning && m.step_id) { 1576 - shownReasoningForStep.add(m.step_id); 1577 - } 1578 - } 1579 - return items; 1580 - })()} 1012 + <TextInput 1013 + style={styles.approvalInput} 1014 + placeholder="Optional reason..." 1015 + placeholderTextColor={theme.colors.text.tertiary} 1016 + value={approvalReason} 1017 + onChangeText={setApprovalReason} 1018 + multiline 1019 + /> 1581 1020 1582 - {/* Streaming message display */} 1583 - {isStreaming && ( 1584 - <View 1585 - style={styles.messageGroup} 1586 - onLayout={(e) => { 1587 - if (!hasPositionedForStream) { 1588 - const y = e.nativeEvent.layout.y; 1589 - // Position so the streaming assistant message starts near the top 1590 - scrollViewRef.current?.scrollToOffset({ offset: Math.max(0, y - 8), animated: false }); 1591 - setHasPositionedForStream(true); 1592 - } 1593 - }} 1594 - > 1595 - {streamingStep && streamingStep.trim().length > 0 && ( 1596 - <View style={styles.reasoningContainer}> 1597 - <Text style={styles.reasoningLabel}>Reasoning</Text> 1598 - <Text style={styles.reasoningText}>{streamingStep.trim()}</Text> 1599 - </View> 1600 - )} 1601 - <View style={[styles.message, styles.assistantMessage, styles.streamingMessage]}> 1602 - {streamingMessage && String(streamingMessage).trim().length > 0 ? ( 1603 - <> 1604 - <MessageContent 1605 - content={String(streamingMessage).trim()} 1606 - isUser={false} 1607 - /> 1608 - <Text style={styles.cursor}>|</Text> 1609 - </> 1610 - ) : ( 1611 - <View style={styles.thinkingIndicator}> 1612 - <ActivityIndicator size="small" color="#666" /> 1613 - <Text style={styles.thinkingText}>Agent is thinking...</Text> 1614 - </View> 1615 - )} 1616 - </View> 1617 - </View> 1618 - )} 1021 + <View style={styles.approvalButtons}> 1022 + <TouchableOpacity 1023 + style={[styles.approvalButton, styles.denyButton]} 1024 + onPress={() => handleApproval(false)} 1025 + disabled={isApproving} 1026 + > 1027 + <Text style={styles.approvalButtonText}>Deny</Text> 1028 + </TouchableOpacity> 1619 1029 1620 - {isSendingMessage && !isStreaming && ( 1621 - <View style={styles.messageGroup}> 1622 - <View style={[styles.message, styles.assistantMessage]}> 1623 - <ActivityIndicator size="small" color="#666" /> 1624 - </View> 1625 - </View> 1030 + <TouchableOpacity 1031 + style={[styles.approvalButton, styles.approveButton]} 1032 + onPress={() => handleApproval(true)} 1033 + disabled={isApproving} 1034 + > 1035 + {isApproving ? ( 1036 + <ActivityIndicator size="small" color="#fff" /> 1037 + ) : ( 1038 + <Text style={styles.approvalButtonText}>Approve</Text> 1626 1039 )} 1627 - 1628 - {/* Inline Approval Request (scrolls with history) */} 1629 - {approvalVisible && ( 1630 - <View style={styles.messageGroup}> 1631 - <View style={[styles.message, styles.assistantMessage]}> 1632 - <View style={styles.approvalCardInline}> 1633 - <Text style={styles.approvalTitle}>Approval Required</Text> 1634 - {!!approvalData?.reasoning && ( 1635 - <View style={styles.reasoningContainer}> 1636 - <Text style={styles.reasoningLabel}>Agent's Reasoning</Text> 1637 - <Text style={styles.reasoningText}>{approvalData?.reasoning}</Text> 1638 - </View> 1639 - )} 1640 - <View style={styles.approvalBlock}> 1641 - <Text style={styles.approvalLabel}>Proposed Tool</Text> 1642 - <Text style={styles.approvalCode}>{approvalData?.toolName}({approvalData?.toolArgs})</Text> 1643 - </View> 1644 - <View style={styles.approvalButtons}> 1645 - <TouchableOpacity 1646 - style={[styles.approvalBtn, styles.approve]} 1647 - disabled={isApproving} 1648 - onPress={async () => { 1649 - if (!currentAgent || !approvalData?.id) return; 1650 - try { 1651 - setIsApproving(true); 1652 - await lettaApi.approveToolRequestStream( 1653 - currentAgent.id, 1654 - { approval_request_id: approvalData.id, approve: true }, 1655 - undefined, 1656 - async () => { 1657 - setApprovalVisible(false); 1658 - setApprovalData(null); 1659 - setApprovalReason(''); 1660 - await loadMessagesForAgent(currentAgent.id); 1661 - }, 1662 - (err) => { 1663 - Alert.alert('Error', err.message || 'Failed to approve'); 1664 - } 1665 - ); 1666 - } finally { 1667 - setIsApproving(false); 1668 - } 1669 - }} 1670 - > 1671 - <Text style={styles.approvalBtnText}>Approve</Text> 1672 - </TouchableOpacity> 1673 - <TouchableOpacity 1674 - style={[styles.approvalBtn, styles.reject]} 1675 - disabled={isApproving} 1676 - onPress={async () => { 1677 - if (!currentAgent || !approvalData?.id) return; 1678 - try { 1679 - setIsApproving(true); 1680 - await lettaApi.approveToolRequestStream( 1681 - currentAgent.id, 1682 - { approval_request_id: approvalData.id, approve: false }, 1683 - undefined, 1684 - async () => { 1685 - setApprovalVisible(false); 1686 - setApprovalData(null); 1687 - setApprovalReason(''); 1688 - await loadMessagesForAgent(currentAgent.id); 1689 - }, 1690 - (err) => { 1691 - Alert.alert('Error', err.message || 'Failed to deny'); 1692 - } 1693 - ); 1694 - } finally { 1695 - setIsApproving(false); 1696 - } 1697 - }} 1698 - > 1699 - <Text style={styles.approvalBtnText}>Reject</Text> 1700 - </TouchableOpacity> 1701 - </View> 1702 - <View style={styles.feedbackBlock}> 1703 - <Text style={styles.approvalLabel}>Reject with feedback</Text> 1704 - <TextInput 1705 - style={styles.approvalInput} 1706 - placeholder="Provide guidance for the agent" 1707 - placeholderTextColor={darkTheme.colors.text.secondary} 1708 - value={approvalReason} 1709 - onChangeText={setApprovalReason} 1710 - multiline 1711 - /> 1712 - <TouchableOpacity 1713 - style={[styles.approvalBtn, styles.reject]} 1714 - disabled={isApproving || approvalReason.trim().length === 0} 1715 - onPress={async () => { 1716 - if (!currentAgent || !approvalData?.id) return; 1717 - try { 1718 - setIsApproving(true); 1719 - await lettaApi.approveToolRequestStream( 1720 - currentAgent.id, 1721 - { approval_request_id: approvalData.id, approve: false, reason: approvalReason.trim() }, 1722 - undefined, 1723 - async () => { 1724 - setApprovalVisible(false); 1725 - setApprovalData(null); 1726 - setApprovalReason(''); 1727 - await loadMessagesForAgent(currentAgent.id); 1728 - }, 1729 - (err) => { 1730 - Alert.alert('Error', err.message || 'Failed to deny with feedback'); 1731 - } 1732 - ); 1733 - } finally { 1734 - setIsApproving(false); 1735 - } 1736 - }} 1737 - > 1738 - <Text style={styles.approvalBtnText}>Send Feedback</Text> 1739 - </TouchableOpacity> 1740 - </View> 1741 - </View> 1742 - </View> 1743 - </View> 1744 - )} 1745 - 1746 - {/* PaddingBottom above reserves vertical room while streaming */} 1747 - </View> 1748 - </ScrollView> 1749 - )} 1750 - </> 1751 - )} 1752 - 1753 - {/* Scroll-to-bottom floating button */} 1754 - {activeSidebarTab !== 'memory' && showScrollToBottom && ( 1755 - <TouchableOpacity 1756 - style={[ 1757 - styles.scrollToBottomBtn, 1758 - { 1759 - bottom: 1760 - Math.max( 1761 - darkTheme.spacing[6], 1762 - inputContainerHeight + (Platform.OS === 'ios' ? insets.bottom : 0) + darkTheme.spacing[2] 1763 - ), 1764 - }, 1765 - ]} 1766 - onPress={scrollToBottom} 1767 - accessibilityLabel="Scroll to latest messages" 1768 - > 1769 - <Ionicons name="chevron-down" size={18} color={darkTheme.colors.text.inverse} /> 1770 - </TouchableOpacity> 1771 - )} 1772 - 1773 - {/* Input */} 1774 - {activeSidebarTab !== 'memory' && ( 1775 - <View style={styles.inputContainer} onLayout={handleInputLayout}> 1776 - <View style={styles.inputWrapper}> 1777 - <TextInput 1778 - style={styles.messageInput} 1779 - value={inputText} 1780 - onChangeText={setInputText} 1781 - multiline 1782 - /> 1783 - <TouchableOpacity 1784 - style={[ 1785 - styles.sendButton, 1786 - (!inputText.trim() || !currentAgent || isSendingMessage || approvalVisible) && styles.sendButtonDisabled 1787 - ]} 1788 - onPress={handleSendMessage} 1789 - disabled={!inputText.trim() || !currentAgent || isSendingMessage || approvalVisible} 1790 - > 1791 - {isSendingMessage ? ( 1792 - <ActivityIndicator size="small" color="#fff" /> 1793 - ) : ( 1794 - <Text style={styles.sendButtonText}>Send</Text> 1795 - )} 1796 - </TouchableOpacity> 1797 - </View> 1040 + </TouchableOpacity> 1798 1041 </View> 1799 - )} 1042 + </View> 1800 1043 </View> 1801 - </View> 1044 + </Modal> 1802 1045 1803 - {/* Project Selector Modal for Chat View */} 1804 - <ProjectSelectorModal 1805 - visible={showProjectSelector} 1806 - currentProject={currentProject} 1807 - onProjectSelect={handleProjectSelect} 1808 - onClose={() => setShowProjectSelector(false)} 1809 - /> 1810 - 1811 - 1812 - <StatusBar 1813 - style={Platform.OS === 'android' ? 'light' : 'auto'} 1814 - backgroundColor={darkTheme.colors.background.secondary} 1815 - /> 1046 + <StatusBar style="auto" /> 1816 1047 </View> 1817 1048 ); 1818 1049 } 1819 1050 1051 + export default function App() { 1052 + return ( 1053 + <SafeAreaProvider> 1054 + <CoApp /> 1055 + </SafeAreaProvider> 1056 + ); 1057 + } 1058 + 1820 1059 const styles = StyleSheet.create({ 1821 1060 container: { 1822 1061 flex: 1, 1823 1062 backgroundColor: darkTheme.colors.background.primary, 1824 1063 }, 1825 - // Setup screen styles 1826 - setupContainer: { 1064 + loadingContainer: { 1827 1065 flex: 1, 1828 1066 justifyContent: 'center', 1829 1067 alignItems: 'center', 1830 - padding: darkTheme.spacing[3], 1831 1068 backgroundColor: darkTheme.colors.background.primary, 1832 1069 }, 1833 - title: { 1834 - fontSize: darkTheme.typography.h1.fontSize, 1835 - fontWeight: darkTheme.typography.h1.fontWeight, 1836 - fontFamily: darkTheme.typography.h1.fontFamily, 1837 - marginBottom: darkTheme.spacing[1], 1838 - color: darkTheme.colors.text.primary, 1839 - letterSpacing: darkTheme.typography.h1.letterSpacing, 1840 - }, 1841 - subtitle: { 1842 - fontSize: darkTheme.typography.body.fontSize, 1843 - fontFamily: darkTheme.typography.body.fontFamily, 1844 - color: darkTheme.colors.text.secondary, 1845 - marginBottom: darkTheme.spacing[4], 1846 - textAlign: 'center', 1847 - lineHeight: darkTheme.typography.body.lineHeight * darkTheme.typography.body.fontSize, 1848 - }, 1849 - input: { 1850 - width: '100%', 1851 - maxWidth: 400, 1852 - height: darkTheme.layout.inputHeight, 1853 - borderWidth: 1, 1854 - borderColor: darkTheme.colors.border.primary, 1855 - borderRadius: darkTheme.layout.borderRadius.medium, 1856 - paddingHorizontal: darkTheme.spacing[2], 1857 - marginBottom: darkTheme.spacing[3], 1858 - backgroundColor: darkTheme.colors.background.surface, 1859 - color: darkTheme.colors.text.primary, 1860 - fontSize: darkTheme.typography.input.fontSize, 1861 - fontFamily: darkTheme.typography.input.fontFamily, 1862 - }, 1863 - button: { 1864 - backgroundColor: darkTheme.colors.interactive.primary, 1865 - paddingHorizontal: darkTheme.spacing[4], 1866 - paddingVertical: darkTheme.spacing[2], 1867 - borderRadius: darkTheme.layout.borderRadius.medium, 1868 - marginBottom: darkTheme.spacing[3], 1869 - shadowColor: darkTheme.colors.interactive.primary, 1870 - shadowOffset: { width: 0, height: 2 }, 1871 - shadowOpacity: 0.3, 1872 - shadowRadius: 4, 1873 - elevation: 4, 1874 - }, 1875 - buttonDisabled: { 1876 - backgroundColor: darkTheme.colors.interactive.disabled, 1877 - shadowOpacity: 0, 1878 - elevation: 0, 1879 - }, 1880 - buttonText: { 1881 - color: darkTheme.colors.text.inverse, 1882 - fontSize: darkTheme.typography.button.fontSize, 1883 - fontWeight: darkTheme.typography.button.fontWeight, 1884 - fontFamily: darkTheme.typography.button.fontFamily, 1885 - textAlign: 'center', 1886 - }, 1887 - instructions: { 1888 - fontSize: darkTheme.typography.caption.fontSize, 1889 - fontFamily: darkTheme.typography.caption.fontFamily, 1070 + loadingText: { 1071 + marginTop: 16, 1072 + fontSize: 16, 1073 + fontFamily: 'Lexend_400Regular', 1890 1074 color: darkTheme.colors.text.secondary, 1891 - textAlign: 'center', 1892 - lineHeight: darkTheme.typography.caption.lineHeight * darkTheme.typography.caption.fontSize, 1893 - }, 1894 - link: { 1895 - color: darkTheme.colors.interactive.primary, 1896 - textDecorationLine: 'underline', 1897 1075 }, 1898 - 1899 - // Main layout styles 1900 - mainLayout: { 1901 - flex: 1, 1902 - flexDirection: 'column', 1903 - backgroundColor: darkTheme.colors.background.primary, 1904 - }, 1905 - desktopLayout: { 1906 - flexDirection: 'row', 1907 - }, 1908 - 1909 - // Mobile sidebar modal 1910 - mobileModal: { 1911 - flex: 1, 1912 - backgroundColor: darkTheme.colors.background.primary, 1913 - }, 1914 - modalHeader: { 1915 - flexDirection: 'row', 1916 - justifyContent: 'flex-end', 1917 - paddingHorizontal: darkTheme.spacing[2], 1918 - paddingVertical: darkTheme.spacing[1.5], 1919 - borderBottomWidth: 1, 1920 - borderBottomColor: darkTheme.colors.border.primary, 1921 - backgroundColor: darkTheme.colors.background.secondary, 1922 - }, 1923 - modalCloseText: { 1924 - fontSize: darkTheme.typography.h6.fontSize, 1925 - color: darkTheme.colors.text.secondary, 1926 - fontWeight: darkTheme.typography.h6.fontWeight, 1927 - fontFamily: darkTheme.typography.h6.fontFamily, 1928 - }, 1929 - 1930 - // Chat area styles 1931 - chatArea: { 1932 - flex: 1, 1933 - flexDirection: 'column', 1934 - backgroundColor: darkTheme.colors.background.primary, 1935 - position: 'relative', 1936 - }, 1937 - chatHeader: { 1076 + header: { 1938 1077 flexDirection: 'row', 1939 1078 alignItems: 'center', 1940 - paddingHorizontal: darkTheme.spacing[2], 1079 + paddingHorizontal: 16, 1080 + paddingBottom: 12, 1941 1081 backgroundColor: darkTheme.colors.background.secondary, 1942 1082 borderBottomWidth: 1, 1943 1083 borderBottomColor: darkTheme.colors.border.primary, 1944 - height: darkTheme.layout.headerHeight, 1945 1084 }, 1946 1085 menuButton: { 1947 - marginRight: darkTheme.spacing[1.5], 1948 - padding: darkTheme.spacing[1], 1086 + padding: 8, 1949 1087 }, 1950 - menuIcon: { 1951 - fontSize: darkTheme.typography.h6.fontSize, 1952 - color: darkTheme.colors.text.secondary, 1953 - fontFamily: darkTheme.typography.h6.fontFamily, 1954 - }, 1955 - 1956 - headerContent: { 1088 + headerCenter: { 1957 1089 flex: 1, 1090 + alignItems: 'center', 1958 1091 }, 1959 - agentTitle: { 1960 - fontSize: darkTheme.typography.agentName.fontSize, 1961 - fontWeight: darkTheme.typography.agentName.fontWeight, 1962 - fontFamily: darkTheme.typography.agentName.fontFamily, 1092 + headerTitle: { 1093 + fontSize: 28, 1094 + fontFamily: 'Lexend_700Bold', 1963 1095 color: darkTheme.colors.text.primary, 1964 - letterSpacing: darkTheme.typography.agentName.letterSpacing, 1965 1096 }, 1966 - agentSubtitle: { 1967 - fontSize: darkTheme.typography.caption.fontSize, 1968 - fontFamily: darkTheme.typography.caption.fontFamily, 1969 - color: darkTheme.colors.text.secondary, 1970 - marginTop: darkTheme.spacing[1], 1971 - }, 1972 - headerAction: { 1973 - fontSize: darkTheme.typography.h5.fontSize, 1974 - color: darkTheme.colors.interactive.secondary, 1975 - fontWeight: '300', 1976 - marginLeft: darkTheme.spacing[1.5], 1977 - padding: darkTheme.spacing[1], 1097 + headerButton: { 1098 + padding: 8, 1978 1099 }, 1979 - headerIconButton: { 1980 - marginLeft: darkTheme.spacing[1.5], 1981 - padding: darkTheme.spacing[1], 1100 + headerButtonDisabled: { 1101 + opacity: 0.3, 1982 1102 }, 1983 - headerPill: { 1984 - marginLeft: darkTheme.spacing[1.5], 1985 - paddingVertical: darkTheme.spacing[0.75] || 6, 1986 - paddingHorizontal: darkTheme.spacing[1.5] || 10, 1987 - borderRadius: darkTheme.layout.borderRadius.round, 1988 - borderWidth: 1, 1989 - borderColor: darkTheme.colors.border.primary, 1990 - backgroundColor: darkTheme.colors.background.surface, 1103 + logoutButton: { 1104 + padding: 8, 1991 1105 }, 1992 - headerPillActive: { 1993 - backgroundColor: darkTheme.colors.interactive.secondary, 1994 - borderColor: darkTheme.colors.interactive.secondary, 1106 + messagesContainer: { 1107 + flex: 1, 1995 1108 }, 1996 - headerPillText: { 1997 - color: darkTheme.colors.text.secondary, 1998 - fontSize: darkTheme.typography.caption.fontSize, 1999 - fontFamily: darkTheme.typography.caption.fontFamily, 1109 + messagesList: { 1110 + maxWidth: 800, 1111 + width: '100%', 1112 + alignSelf: 'center', 1113 + paddingBottom: 100, // Space for input at bottom 2000 1114 }, 2001 - headerPillTextActive: { 2002 - color: darkTheme.colors.text.inverse, 2003 - fontWeight: '600', 1115 + messageContainer: { 1116 + paddingHorizontal: 16, 1117 + paddingVertical: 8, 2004 1118 }, 2005 - segmentedToggle: { 2006 - flexDirection: 'row', 2007 - marginLeft: darkTheme.spacing[1.5], 2008 - borderWidth: 1, 2009 - borderColor: darkTheme.colors.border.primary, 2010 - backgroundColor: darkTheme.colors.background.surface, 2011 - borderRadius: darkTheme.layout.borderRadius.round, 2012 - overflow: 'hidden', 1119 + userMessageContainer: { 1120 + alignItems: 'flex-end', 2013 1121 }, 2014 - segmentButton: { 2015 - paddingVertical: darkTheme.spacing[0.75] || 6, 2016 - paddingHorizontal: darkTheme.spacing[1.5] || 10, 1122 + assistantMessageContainer: { 1123 + alignItems: 'flex-start', 2017 1124 }, 2018 - segmentRight: { 2019 - borderLeftWidth: 1, 2020 - borderLeftColor: darkTheme.colors.border.primary, 1125 + assistantFullWidthContainer: { 1126 + paddingHorizontal: 16, 1127 + paddingVertical: 12, 1128 + width: '100%', 2021 1129 }, 2022 - segmentButtonActive: { 2023 - backgroundColor: darkTheme.colors.interactive.secondary, 2024 - }, 2025 - segmentText: { 2026 - color: darkTheme.colors.text.secondary, 2027 - fontSize: darkTheme.typography.caption.fontSize, 2028 - fontFamily: darkTheme.typography.caption.fontFamily, 1130 + messageBubble: { 1131 + maxWidth: 600, 1132 + padding: 12, 1133 + borderRadius: 24, 2029 1134 }, 2030 - segmentTextActive: { 2031 - color: darkTheme.colors.text.inverse, 2032 - fontWeight: '600', 1135 + userBubble: { 1136 + // Background color set dynamically per theme in render 2033 1137 }, 2034 - // Approval message card (inline in the chat) 2035 - approvalCardInline: { 2036 - width: '100%', 2037 - backgroundColor: darkTheme.colors.background.surface, 1138 + assistantBubble: { 1139 + backgroundColor: darkTheme.colors.background.secondary, 2038 1140 borderWidth: 1, 2039 1141 borderColor: darkTheme.colors.border.primary, 2040 - borderRadius: 0, 2041 - padding: darkTheme.spacing[2], 2042 1142 }, 2043 - approvalTitle: { 2044 - fontSize: darkTheme.typography.h5.fontSize, 2045 - fontWeight: darkTheme.typography.h5.fontWeight, 2046 - fontFamily: darkTheme.typography.h5.fontFamily, 2047 - color: darkTheme.colors.text.primary, 2048 - marginBottom: darkTheme.spacing[1.5], 2049 - }, 2050 - approvalBlock: { 2051 - marginTop: darkTheme.spacing[1], 2052 - marginBottom: darkTheme.spacing[2], 1143 + userMessageText: { 1144 + color: darkTheme.colors.background.primary, 1145 + fontSize: 16, 1146 + fontFamily: 'Lexend_400Regular', 2053 1147 }, 2054 - approvalLabel: { 2055 - fontSize: darkTheme.typography.label.fontSize, 2056 - fontFamily: darkTheme.typography.label.fontFamily, 2057 - fontWeight: darkTheme.typography.label.fontWeight, 2058 - color: darkTheme.colors.text.secondary, 2059 - letterSpacing: darkTheme.typography.label.letterSpacing, 2060 - textTransform: 'uppercase', 2061 - marginBottom: darkTheme.spacing[0.5], 2062 - }, 2063 - approvalCode: { 2064 - fontFamily: 'Menlo', 2065 - fontSize: 13, 1148 + assistantMessageText: { 2066 1149 color: darkTheme.colors.text.primary, 2067 - backgroundColor: darkTheme.colors.background.tertiary, 2068 - padding: darkTheme.spacing[1], 2069 - borderTopWidth: StyleSheet.hairlineWidth, 2070 - borderBottomWidth: StyleSheet.hairlineWidth, 2071 - borderColor: darkTheme.colors.border.primary, 1150 + fontSize: 16, 1151 + fontFamily: 'Lexend_400Regular', 2072 1152 }, 2073 - approvalButtons: { 1153 + reasoningToggle: { 2074 1154 flexDirection: 'row', 2075 - gap: darkTheme.spacing[1], 2076 - marginTop: darkTheme.spacing[1], 2077 - }, 2078 - approvalBtn: { 2079 - flex: 1, 2080 - borderRadius: 0, 2081 - paddingVertical: darkTheme.spacing[1.5] || darkTheme.spacing[1], 2082 1155 alignItems: 'center', 2083 - borderWidth: 1, 1156 + paddingVertical: 4, 1157 + marginBottom: 8, 2084 1158 }, 2085 - approve: { 2086 - backgroundColor: darkTheme.colors.interactive.primary, 2087 - borderColor: darkTheme.colors.interactive.primary, 1159 + reasoningToggleText: { 1160 + fontSize: 13, 1161 + fontFamily: 'Lexend_400Regular', 1162 + color: darkTheme.colors.text.tertiary, 1163 + marginRight: 4, 2088 1164 }, 2089 - reject: { 2090 - backgroundColor: 'transparent', 2091 - borderColor: darkTheme.colors.border.primary, 1165 + reasoningExpandedContainer: { 1166 + paddingVertical: 8, 1167 + paddingHorizontal: 12, 1168 + marginBottom: 12, 1169 + backgroundColor: 'rgba(255, 255, 255, 0.02)', 1170 + borderRadius: 6, 1171 + borderLeftWidth: 2, 1172 + borderLeftColor: darkTheme.colors.text.tertiary, 2092 1173 }, 2093 - approvalBtnText: { 2094 - color: darkTheme.colors.text.inverse, 2095 - fontSize: darkTheme.typography.buttonSmall.fontSize, 2096 - fontWeight: darkTheme.typography.buttonSmall.fontWeight, 2097 - fontFamily: darkTheme.typography.buttonSmall.fontFamily, 1174 + reasoningExpandedText: { 1175 + fontSize: 13, 1176 + fontFamily: 'Lexend_400Regular', 1177 + color: darkTheme.colors.text.tertiary, 1178 + lineHeight: 18, 1179 + fontStyle: 'italic', 2098 1180 }, 2099 - feedbackBlock: { 2100 - marginTop: darkTheme.spacing[2], 1181 + reasoningContainer: { 1182 + marginTop: 8, 1183 + paddingTop: 8, 1184 + borderTopWidth: 1, 1185 + borderTopColor: darkTheme.colors.border.primary, 2101 1186 }, 2102 - approvalInput: { 2103 - minHeight: 80, 2104 - borderWidth: 1, 2105 - borderColor: darkTheme.colors.border.primary, 2106 - backgroundColor: darkTheme.colors.background.tertiary, 2107 - color: darkTheme.colors.text.primary, 2108 - padding: darkTheme.spacing[1], 2109 - marginTop: darkTheme.spacing[0.5], 2110 - marginBottom: darkTheme.spacing[1], 1187 + reasoningLabel: { 1188 + fontSize: 12, 1189 + fontFamily: 'Lexend_600SemiBold', 1190 + color: darkTheme.colors.text.secondary, 1191 + marginBottom: 4, 2111 1192 }, 2112 - 2113 - // Messages styles 2114 - messagesContainer: { 2115 - flex: 1, 2116 - backgroundColor: darkTheme.colors.background.primary, 1193 + reasoningText: { 1194 + fontSize: 12, 1195 + fontFamily: 'Lexend_400Regular', 1196 + color: darkTheme.colors.text.tertiary, 1197 + fontStyle: 'italic', 2117 1198 }, 2118 - messagesList: { 2119 - maxWidth: darkTheme.layout.maxContentWidth, 2120 - alignSelf: 'center', 2121 - width: '100%', 2122 - paddingHorizontal: darkTheme.spacing[3], 2123 - paddingVertical: darkTheme.spacing[2], 1199 + loadMoreButton: { 1200 + padding: 16, 1201 + alignItems: 'center', 2124 1202 }, 2125 - // Memory block list styles 2126 - blockItem: { 2127 - paddingVertical: darkTheme.spacing[1.5], 2128 - paddingHorizontal: darkTheme.spacing[2], 2129 - marginHorizontal: darkTheme.spacing[2], 2130 - marginBottom: darkTheme.spacing[1], 2131 - borderRadius: darkTheme.layout.borderRadius.medium, 2132 - borderWidth: 1, 2133 - borderColor: darkTheme.colors.border.primary, 2134 - backgroundColor: darkTheme.colors.background.secondary, 2135 - }, 2136 - blockLabel: { 2137 - fontSize: darkTheme.typography.caption.fontSize, 2138 - fontFamily: darkTheme.typography.caption.fontFamily, 1203 + loadMoreText: { 2139 1204 color: darkTheme.colors.text.secondary, 2140 - marginBottom: darkTheme.spacing[0.5], 2141 - }, 2142 - blockName: { 2143 - fontSize: darkTheme.typography.h6.fontSize, 2144 - fontWeight: darkTheme.typography.h6.fontWeight, 2145 - fontFamily: darkTheme.typography.h6.fontFamily, 2146 - color: darkTheme.colors.text.primary, 1205 + fontSize: 14, 1206 + fontFamily: 'Lexend_400Regular', 2147 1207 }, 2148 - blockDesc: { 2149 - marginTop: darkTheme.spacing[0.5], 2150 - fontSize: darkTheme.typography.caption.fontSize, 2151 - fontFamily: darkTheme.typography.caption.fontFamily, 1208 + streamingStep: { 1209 + fontSize: 12, 1210 + fontFamily: 'Lexend_400Regular', 2152 1211 color: darkTheme.colors.text.secondary, 1212 + fontStyle: 'italic', 1213 + marginBottom: 8, 2153 1214 }, 2154 - messageGroup: { 2155 - marginBottom: darkTheme.spacing.messageGap, 2156 - }, 2157 - loadingContainer: { 1215 + emptyContainer: { 2158 1216 flex: 1, 2159 1217 justifyContent: 'center', 2160 1218 alignItems: 'center', 2161 - padding: darkTheme.spacing[3], 2162 - backgroundColor: darkTheme.colors.background.primary, 2163 - }, 2164 - loadingText: { 2165 - marginTop: darkTheme.spacing[1.5], 2166 - fontSize: darkTheme.typography.body.fontSize, 2167 - fontFamily: darkTheme.typography.body.fontFamily, 2168 - color: darkTheme.colors.text.secondary, 2169 - }, 2170 - emptyContainer: { 2171 - alignItems: 'center', 2172 - justifyContent: 'center', 2173 - paddingVertical: darkTheme.spacing[10], 1219 + padding: 40, 2174 1220 }, 2175 1221 emptyText: { 2176 - fontSize: darkTheme.typography.body.fontSize, 2177 - fontFamily: darkTheme.typography.body.fontFamily, 1222 + fontSize: 16, 1223 + fontFamily: 'Lexend_400Regular', 2178 1224 color: darkTheme.colors.text.secondary, 2179 1225 textAlign: 'center', 2180 - lineHeight: darkTheme.typography.body.lineHeight * darkTheme.typography.body.fontSize, 2181 1226 }, 2182 - 2183 - // Message styles (Letta design system) 2184 - message: { 2185 - paddingVertical: darkTheme.spacing[0.5], 2186 - }, 2187 - userMessage: { 2188 - alignSelf: 'flex-end', 2189 - maxWidth: '70%', 1227 + scrollToBottomButton: { 1228 + position: 'absolute', 1229 + bottom: 16, 1230 + right: 16, 1231 + width: 48, 1232 + height: 48, 1233 + borderRadius: 24, 2190 1234 backgroundColor: darkTheme.colors.interactive.primary, 2191 - paddingHorizontal: darkTheme.spacing[2], 2192 - paddingVertical: darkTheme.spacing[1], 2193 - borderRadius: darkTheme.layout.borderRadius.large, 2194 - borderBottomRightRadius: darkTheme.layout.borderRadius.small, 2195 - borderWidth: 0, 2196 - borderColor: 'transparent', 2197 - // Subtle lift so user bubbles float off the page 1235 + justifyContent: 'center', 1236 + alignItems: 'center', 2198 1237 shadowColor: '#000', 2199 - shadowOffset: { width: 0, height: 3 }, 2200 - shadowOpacity: 0.18, 2201 - shadowRadius: 8, 2202 - elevation: 4, 2203 - }, 2204 - assistantMessage: { 2205 - alignSelf: 'flex-start', 2206 - maxWidth: '100%', 2207 - paddingHorizontal: darkTheme.spacing[1], 2208 - paddingVertical: darkTheme.spacing[0.5], 2209 - }, 2210 - messageText: { 2211 - fontSize: 15, // Refined font size (15px) 2212 - fontFamily: darkTheme.typography.chatMessage.fontFamily, 2213 - fontWeight: '400', // Regular weight 2214 - lineHeight: 1.6 * 15, // 1.6 line height ratio for readability 2215 - letterSpacing: darkTheme.typography.chatMessage.letterSpacing, 2216 - // Ensure long tokens (URLs) can wrap in bubbles 2217 - wordBreak: 'break-word' as any, 2218 - overflowWrap: 'anywhere' as any, 2219 - whiteSpace: 'pre-wrap' as any, 2220 - }, 2221 - codeText: { 2222 - fontSize: 13, 2223 - fontFamily: 'Menlo', 2224 - color: darkTheme.colors.text.primary, 2225 - }, 2226 - userText: { 2227 - color: darkTheme.colors.text.primary, 2228 - }, 2229 - assistantText: { 2230 - color: darkTheme.colors.text.primary, 2231 - }, 2232 - 2233 - // Reasoning styles (Subtle and readable) 2234 - reasoningContainer: { 2235 - backgroundColor: 'transparent', 2236 - // Align with assistant message horizontal padding 2237 - paddingHorizontal: darkTheme.spacing[1], 2238 - paddingTop: darkTheme.spacing[1], 2239 - paddingBottom: darkTheme.spacing[0.5], 2240 - // Breathing room from previous block (e.g., tool call) 2241 - marginTop: darkTheme.spacing[1], 2242 - marginBottom: darkTheme.spacing[1], 2243 - }, 2244 - reasoningText: { 2245 - fontSize: 14, // Slightly larger for readability 2246 - fontFamily: darkTheme.typography.reasoning.fontFamily, 2247 - fontWeight: '400', // Regular weight for better readability 2248 - color: 'rgba(184, 184, 184, 0.8)', // More visible gray 2249 - fontStyle: darkTheme.typography.reasoning.fontStyle, 2250 - lineHeight: 1.5 * 14, // Comfortable line height 2251 - letterSpacing: 0.01, // Minimal letter spacing 2252 - }, 2253 - reasoningLabel: { 2254 - color: 'rgba(184, 184, 184, 0.8)', 2255 - fontSize: 11, 2256 - textTransform: 'uppercase', 2257 - letterSpacing: 0.6, 2258 - marginBottom: darkTheme.spacing[0.5], 2259 - }, 2260 - 2261 - // Streaming styles (Technical indicators) 2262 - streamingMessage: { 2263 - borderLeftWidth: 0, 2264 - paddingLeft: 0, 2265 - opacity: 0.9, 1238 + shadowOffset: { width: 0, height: 2 }, 1239 + shadowOpacity: 0.25, 1240 + shadowRadius: 3.84, 1241 + elevation: 5, 2266 1242 }, 2267 - cursor: { 2268 - color: darkTheme.colors.text.secondary, 2269 - fontWeight: 'bold', 2270 - opacity: 0.6, 2271 - }, 2272 - thinkingIndicator: { 2273 - flexDirection: 'row', 1243 + inputContainer: { 1244 + position: 'absolute', 1245 + bottom: 0, 1246 + left: 0, 1247 + right: 0, 1248 + paddingTop: 16, 1249 + paddingHorizontal: 16, 2274 1250 alignItems: 'center', 2275 - padding: darkTheme.spacing[1], 2276 1251 }, 2277 - thinkingText: { 2278 - fontSize: darkTheme.typography.reasoning.fontSize, 2279 - fontFamily: darkTheme.typography.reasoning.fontFamily, 2280 - color: darkTheme.colors.text.secondary, 2281 - fontStyle: darkTheme.typography.reasoning.fontStyle, 2282 - marginLeft: darkTheme.spacing[1], 1252 + inputCentered: { 1253 + position: 'relative', 1254 + maxWidth: 800, 1255 + width: '100%', 2283 1256 }, 2284 - 2285 - // Input styles (Floating glass design) 2286 - inputContainer: { 2287 - backgroundColor: 'transparent', 2288 - paddingHorizontal: darkTheme.spacing[3], 2289 - paddingVertical: darkTheme.spacing[2], 2290 - paddingBottom: darkTheme.spacing[3], // Extra bottom padding for floating effect 1257 + inputBackdrop: { 1258 + position: 'absolute', 1259 + top: 0, 1260 + left: 0, 1261 + right: 0, 1262 + bottom: 0, 1263 + borderRadius: 28, 1264 + zIndex: -1, 2291 1265 }, 2292 1266 inputWrapper: { 2293 - maxWidth: darkTheme.layout.maxInputWidth, 2294 - alignSelf: 'center', 2295 - width: '100%', 1267 + position: 'relative', 2296 1268 flexDirection: 'row', 2297 1269 alignItems: 'flex-end', 2298 - backgroundColor: darkTheme.colors.background.primary, 2299 - borderRadius: darkTheme.layout.borderRadius.large, 2300 - padding: darkTheme.spacing[0.5], 2301 - borderWidth: 1, 2302 - borderColor: darkTheme.colors.border.primary, 2303 - shadowColor: 'transparent', 2304 - shadowOffset: { width: 0, height: 0 }, 2305 - shadowOpacity: 0, 2306 - shadowRadius: 0, 2307 - elevation: 0, 2308 - }, 2309 - messageInput: { 2310 - flex: 1, 2311 - borderWidth: 0, 2312 - borderColor: 'transparent', 2313 - borderRadius: darkTheme.layout.borderRadius.large, 2314 - paddingHorizontal: darkTheme.spacing[2], 2315 - paddingVertical: darkTheme.spacing[1.5], 2316 - marginRight: darkTheme.spacing[1], 2317 - maxHeight: 120, 2318 - fontSize: darkTheme.typography.input.fontSize, 2319 - fontFamily: darkTheme.typography.input.fontFamily, 2320 - backgroundColor: 'transparent', 2321 - color: darkTheme.colors.text.primary, 2322 - // Remove noisy blue focus ring on web 2323 - ...(Platform.OS === 'web' && { 2324 - outlineStyle: 'none' as any, 2325 - outlineWidth: 0, 2326 - outlineColor: 'transparent', 2327 - }), 2328 1270 }, 2329 1271 sendButton: { 2330 - backgroundColor: darkTheme.colors.interactive.secondary, 2331 - borderRadius: darkTheme.layout.borderRadius.round, 2332 - paddingHorizontal: darkTheme.spacing[2.5], 2333 - paddingVertical: darkTheme.spacing[1.5], 2334 - minWidth: 50, 1272 + position: 'absolute', 1273 + right: 10, 1274 + top: '50%', 1275 + transform: [{ translateY: -18 }], 1276 + width: 36, 1277 + height: 36, 1278 + borderRadius: 18, 1279 + backgroundColor: darkTheme.colors.interactive.primary, 2335 1280 justifyContent: 'center', 2336 1281 alignItems: 'center', 2337 - shadowColor: darkTheme.colors.interactive.secondary, 2338 - shadowOffset: { width: 0, height: 2 }, 2339 - shadowOpacity: 0.3, 2340 - shadowRadius: 4, 2341 - elevation: 4, 1282 + }, 1283 + sendRing: { 1284 + width: 20, 1285 + height: 20, 1286 + borderRadius: 10, 1287 + borderWidth: 2, 1288 + borderColor: darkTheme.colors.background.primary, 2342 1289 }, 2343 1290 sendButtonDisabled: { 2344 - backgroundColor: darkTheme.colors.interactive.disabled, 2345 - shadowOpacity: 0, 2346 - elevation: 0, 1291 + opacity: 0.5, 1292 + }, 1293 + modalOverlay: { 1294 + flex: 1, 1295 + backgroundColor: 'rgba(0, 0, 0, 0.5)', 1296 + justifyContent: 'flex-end', 1297 + }, 1298 + sidebarContainer: { 1299 + width: '100%', 1300 + maxHeight: '80%', 1301 + backgroundColor: darkTheme.colors.background.primary, 1302 + borderTopLeftRadius: 16, 1303 + borderTopRightRadius: 16, 1304 + padding: 16, 2347 1305 }, 2348 - sendButtonText: { 2349 - color: darkTheme.colors.text.inverse, 2350 - fontSize: darkTheme.typography.buttonSmall.fontSize, 2351 - fontWeight: darkTheme.typography.buttonSmall.fontWeight, 2352 - fontFamily: darkTheme.typography.buttonSmall.fontFamily, 2353 - textAlign: 'center', 1306 + closeSidebar: { 1307 + alignSelf: 'flex-end', 1308 + padding: 8, 2354 1309 }, 2355 - loadMoreBtn: { 2356 - alignSelf: 'center', 2357 - marginVertical: darkTheme.spacing[1], 2358 - paddingVertical: darkTheme.spacing[0.75] || 6, 2359 - paddingHorizontal: darkTheme.spacing[1.5] || 10, 2360 - borderRadius: darkTheme.layout.borderRadius.round, 1310 + sidebarTitle: { 1311 + fontSize: 24, 1312 + fontFamily: 'Lexend_700Bold', 1313 + color: darkTheme.colors.text.primary, 1314 + marginBottom: 16, 1315 + }, 1316 + memoryBlockItem: { 1317 + padding: 16, 1318 + backgroundColor: darkTheme.colors.background.secondary, 1319 + borderRadius: 8, 1320 + marginBottom: 12, 2361 1321 borderWidth: 1, 2362 1322 borderColor: darkTheme.colors.border.primary, 2363 - backgroundColor: darkTheme.colors.background.surface, 1323 + }, 1324 + memoryBlockLabel: { 1325 + fontSize: 16, 1326 + fontFamily: 'Lexend_600SemiBold', 1327 + color: darkTheme.colors.text.primary, 1328 + marginBottom: 4, 2364 1329 }, 2365 - loadMoreText: { 1330 + memoryBlockPreview: { 1331 + fontSize: 14, 1332 + fontFamily: 'Lexend_400Regular', 2366 1333 color: darkTheme.colors.text.secondary, 2367 - fontSize: darkTheme.typography.caption.fontSize, 2368 - fontFamily: darkTheme.typography.caption.fontFamily, 1334 + }, 1335 + detailContainer: { 1336 + width: '90%', 1337 + maxHeight: '80%', 1338 + backgroundColor: darkTheme.colors.background.primary, 1339 + borderRadius: 16, 1340 + padding: 20, 1341 + alignSelf: 'center', 1342 + marginTop: 'auto', 1343 + marginBottom: 'auto', 2369 1344 }, 2370 - 2371 - // Floating controls 2372 - scrollToBottomBtn: { 2373 - position: 'absolute', 2374 - right: darkTheme.spacing[2], 2375 - bottom: darkTheme.spacing[10], 2376 - width: 40, 2377 - height: 40, 2378 - borderRadius: 20, 2379 - backgroundColor: darkTheme.colors.interactive.secondary, 2380 - justifyContent: 'center', 1345 + detailHeader: { 1346 + flexDirection: 'row', 1347 + justifyContent: 'space-between', 2381 1348 alignItems: 'center', 2382 - shadowColor: '#000', 2383 - shadowOffset: { width: 0, height: 2 }, 2384 - shadowOpacity: 0.25, 2385 - shadowRadius: 3.84, 2386 - elevation: 5, 2387 - borderWidth: 1, 2388 - borderColor: darkTheme.colors.border.primary, 1349 + marginBottom: 16, 2389 1350 }, 2390 - 2391 - // Legacy modal styles (for project selector, etc.) 2392 - modalOverlay: { 1351 + detailTitle: { 1352 + fontSize: 20, 1353 + fontFamily: 'Lexend_700Bold', 1354 + color: darkTheme.colors.text.primary, 1355 + }, 1356 + detailContent: { 1357 + fontSize: 16, 1358 + fontFamily: 'Lexend_400Regular', 1359 + color: darkTheme.colors.text.primary, 1360 + lineHeight: 24, 1361 + }, 1362 + errorText: { 1363 + color: darkTheme.colors.status.error, 1364 + fontSize: 14, 1365 + fontFamily: 'Lexend_400Regular', 1366 + textAlign: 'center', 1367 + }, 1368 + approvalOverlay: { 2393 1369 flex: 1, 2394 - backgroundColor: 'rgba(10, 10, 10, 0.8)', 1370 + backgroundColor: 'rgba(0, 0, 0, 0.7)', 2395 1371 justifyContent: 'center', 2396 1372 alignItems: 'center', 1373 + padding: 20, 2397 1374 }, 2398 - modalContent: { 2399 - backgroundColor: darkTheme.colors.background.surface, 2400 - borderRadius: darkTheme.layout.borderRadius.large, 2401 - padding: darkTheme.spacing[3], 2402 - width: '90%', 1375 + approvalContainer: { 1376 + width: '100%', 2403 1377 maxWidth: 400, 2404 - maxHeight: '80%', 2405 - borderWidth: 1, 2406 - borderColor: darkTheme.colors.border.primary, 2407 - shadowColor: darkTheme.colors.text.primary, 2408 - shadowOffset: { width: 0, height: 8 }, 2409 - shadowOpacity: 0.3, 2410 - shadowRadius: 16, 2411 - elevation: 8, 1378 + backgroundColor: darkTheme.colors.background.primary, 1379 + borderRadius: 16, 1380 + padding: 20, 2412 1381 }, 2413 - modalTitle: { 2414 - fontSize: darkTheme.typography.h5.fontSize, 2415 - fontWeight: darkTheme.typography.h5.fontWeight, 2416 - fontFamily: darkTheme.typography.h5.fontFamily, 1382 + approvalTitle: { 1383 + fontSize: 20, 1384 + fontFamily: 'Lexend_700Bold', 2417 1385 color: darkTheme.colors.text.primary, 2418 - marginBottom: darkTheme.spacing[2], 2419 - textAlign: 'center', 1386 + marginBottom: 16, 2420 1387 }, 2421 - agentList: { 2422 - maxHeight: 300, 1388 + approvalTool: { 1389 + fontSize: 16, 1390 + fontFamily: 'Lexend_400Regular', 1391 + color: darkTheme.colors.text.primary, 1392 + marginBottom: 12, 2423 1393 }, 2424 - agentItem: { 2425 - padding: darkTheme.spacing[2], 2426 - borderBottomWidth: 1, 2427 - borderBottomColor: darkTheme.colors.border.secondary, 2428 - borderRadius: darkTheme.layout.borderRadius.medium, 2429 - marginBottom: darkTheme.spacing[0.5], 1394 + approvalReasoning: { 1395 + backgroundColor: darkTheme.colors.background.secondary, 1396 + padding: 12, 1397 + borderRadius: 8, 1398 + marginBottom: 16, 2430 1399 }, 2431 - selectedAgentItem: { 2432 - backgroundColor: darkTheme.colors.background.tertiary, 2433 - borderLeftWidth: 3, 2434 - borderLeftColor: darkTheme.colors.interactive.primary, 1400 + approvalReasoningLabel: { 1401 + fontSize: 12, 1402 + fontFamily: 'Lexend_600SemiBold', 1403 + color: darkTheme.colors.text.secondary, 1404 + marginBottom: 4, 2435 1405 }, 2436 - agentName: { 2437 - fontSize: darkTheme.typography.body.fontSize, 2438 - fontWeight: darkTheme.typography.agentName.fontWeight, 2439 - fontFamily: darkTheme.typography.agentName.fontFamily, 1406 + approvalReasoningText: { 1407 + fontSize: 14, 1408 + fontFamily: 'Lexend_400Regular', 2440 1409 color: darkTheme.colors.text.primary, 2441 - marginBottom: darkTheme.spacing[0.5], 2442 - }, 2443 - agentDescription: { 2444 - fontSize: darkTheme.typography.caption.fontSize, 2445 - fontFamily: darkTheme.typography.caption.fontFamily, 2446 - color: darkTheme.colors.text.secondary, 2447 1410 }, 2448 - modalCloseButton: { 2449 - marginTop: darkTheme.spacing[2], 2450 - backgroundColor: darkTheme.colors.interactive.primary, 2451 - borderRadius: darkTheme.layout.borderRadius.medium, 2452 - paddingVertical: darkTheme.spacing[1.5], 2453 - }, 2454 - modalInput: { 1411 + approvalInput: { 1412 + height: 80, 2455 1413 borderWidth: 1, 2456 1414 borderColor: darkTheme.colors.border.primary, 2457 - borderRadius: darkTheme.layout.borderRadius.medium, 2458 - paddingHorizontal: darkTheme.spacing[1.5], 2459 - paddingVertical: darkTheme.spacing[1.5], 2460 - marginBottom: darkTheme.spacing[3], 2461 - fontSize: darkTheme.typography.input.fontSize, 2462 - fontFamily: darkTheme.typography.input.fontFamily, 2463 - backgroundColor: darkTheme.colors.background.tertiary, 1415 + borderRadius: 8, 1416 + padding: 12, 1417 + fontFamily: 'Lexend_400Regular', 2464 1418 color: darkTheme.colors.text.primary, 1419 + backgroundColor: darkTheme.colors.background.secondary, 1420 + marginBottom: 16, 1421 + textAlignVertical: 'top', 2465 1422 }, 2466 - modalButtons: { 1423 + approvalButtons: { 2467 1424 flexDirection: 'row', 2468 1425 justifyContent: 'space-between', 2469 - gap: darkTheme.spacing[1.5], 2470 1426 }, 2471 - modalCancelButton: { 1427 + approvalButton: { 2472 1428 flex: 1, 2473 - borderWidth: 1, 2474 - borderColor: darkTheme.colors.border.primary, 2475 - borderRadius: darkTheme.layout.borderRadius.medium, 2476 - paddingVertical: darkTheme.spacing[1.5], 1429 + height: 48, 1430 + borderRadius: 8, 1431 + justifyContent: 'center', 1432 + alignItems: 'center', 1433 + marginHorizontal: 4, 2477 1434 }, 2478 - modalCancelText: { 2479 - color: darkTheme.colors.text.secondary, 2480 - fontSize: darkTheme.typography.button.fontSize, 2481 - fontWeight: darkTheme.typography.button.fontWeight, 2482 - fontFamily: darkTheme.typography.button.fontFamily, 2483 - textAlign: 'center', 1435 + denyButton: { 1436 + backgroundColor: darkTheme.colors.status.error, 1437 + }, 1438 + approveButton: { 1439 + backgroundColor: darkTheme.colors.status.success, 2484 1440 }, 2485 - modalCreateButton: { 2486 - flex: 1, 1441 + approvalButtonText: { 1442 + color: darkTheme.colors.background.primary, 1443 + fontSize: 16, 1444 + fontFamily: 'Lexend_600SemiBold', 1445 + }, 1446 + typingCursor: { 1447 + width: 2, 1448 + height: 20, 2487 1449 backgroundColor: darkTheme.colors.interactive.primary, 2488 - borderRadius: darkTheme.layout.borderRadius.medium, 2489 - paddingVertical: darkTheme.spacing[1.5], 2490 - shadowColor: darkTheme.colors.interactive.primary, 2491 - shadowOffset: { width: 0, height: 2 }, 2492 - shadowOpacity: 0.3, 2493 - shadowRadius: 4, 2494 - elevation: 4, 2495 - }, 2496 - modalCreateText: { 2497 - color: darkTheme.colors.text.inverse, 2498 - fontSize: darkTheme.typography.button.fontSize, 2499 - fontWeight: darkTheme.typography.button.fontWeight, 2500 - fontFamily: darkTheme.typography.button.fontFamily, 2501 - textAlign: 'center', 1450 + marginLeft: 2, 1451 + marginTop: 2, 2502 1452 }, 2503 1453 }); 2504 - 2505 - export default function App() { 2506 - const [fontsLoaded] = useFonts({ 2507 - Roobert: require('./assets/fonts/Roobert-Regular.ttf'), 2508 - }); 2509 - 2510 - if (!fontsLoaded) { 2511 - return null; 2512 - } 2513 - 2514 - return ( 2515 - <SafeAreaProvider> 2516 - <MainApp /> 2517 - </SafeAreaProvider> 2518 - ); 2519 - }
+169
CoLoginScreen.tsx
··· 1 + import React, { useState } from 'react'; 2 + import { 3 + View, 4 + Text, 5 + StyleSheet, 6 + TextInput, 7 + TouchableOpacity, 8 + ActivityIndicator, 9 + SafeAreaView, 10 + useColorScheme, 11 + } from 'react-native'; 12 + import { StatusBar } from 'expo-status-bar'; 13 + import { useFonts, Lexend_400Regular, Lexend_600SemiBold, Lexend_700Bold } from '@expo-google-fonts/lexend'; 14 + import { darkTheme } from './src/theme'; 15 + 16 + interface CoLoginScreenProps { 17 + onLogin: (apiKey: string) => Promise<void>; 18 + isLoading: boolean; 19 + error: string | null; 20 + } 21 + 22 + export default function CoLoginScreen({ onLogin, isLoading, error }: CoLoginScreenProps) { 23 + const colorScheme = useColorScheme(); 24 + const [apiKey, setApiKey] = useState(''); 25 + 26 + const [fontsLoaded] = useFonts({ 27 + Lexend_400Regular, 28 + Lexend_600SemiBold, 29 + Lexend_700Bold, 30 + }); 31 + 32 + if (!fontsLoaded) { 33 + return null; 34 + } 35 + 36 + const handleLogin = async () => { 37 + if (!apiKey.trim()) return; 38 + await onLogin(apiKey.trim()); 39 + }; 40 + 41 + return ( 42 + <SafeAreaView style={styles.container}> 43 + <View style={styles.content}> 44 + <View style={styles.header}> 45 + <Text style={styles.title}>co</Text> 46 + </View> 47 + 48 + <View style={styles.form}> 49 + <Text style={styles.label}>Letta API Key</Text> 50 + <TextInput 51 + style={styles.input} 52 + placeholder="Enter your Letta API key" 53 + placeholderTextColor={darkTheme.colors.text.tertiary} 54 + value={apiKey} 55 + onChangeText={setApiKey} 56 + autoCapitalize="none" 57 + autoCorrect={false} 58 + autoFocus 59 + editable={!isLoading} 60 + secureTextEntry 61 + /> 62 + 63 + {error && ( 64 + <View style={styles.errorContainer}> 65 + <Text style={styles.errorText}>{error}</Text> 66 + </View> 67 + )} 68 + 69 + <TouchableOpacity 70 + style={[styles.button, isLoading && styles.buttonDisabled]} 71 + onPress={handleLogin} 72 + disabled={isLoading || !apiKey.trim()} 73 + > 74 + {isLoading ? ( 75 + <ActivityIndicator size="small" color="#fff" /> 76 + ) : ( 77 + <Text style={styles.buttonText}>Connect</Text> 78 + )} 79 + </TouchableOpacity> 80 + 81 + <Text style={styles.helpText}> 82 + Don't have an API key? Visit letta.com to create one. 83 + </Text> 84 + </View> 85 + </View> 86 + <StatusBar style="auto" /> 87 + </SafeAreaView> 88 + ); 89 + } 90 + 91 + const styles = StyleSheet.create({ 92 + container: { 93 + flex: 1, 94 + backgroundColor: darkTheme.colors.background.primary, 95 + }, 96 + content: { 97 + flex: 1, 98 + justifyContent: 'center', 99 + paddingHorizontal: 32, 100 + }, 101 + header: { 102 + alignItems: 'center', 103 + marginBottom: 48, 104 + }, 105 + title: { 106 + fontSize: 48, 107 + fontFamily: 'Lexend_700Bold', 108 + color: darkTheme.colors.text.primary, 109 + letterSpacing: -1, 110 + }, 111 + form: { 112 + width: '100%', 113 + maxWidth: 400, 114 + alignSelf: 'center', 115 + }, 116 + label: { 117 + fontSize: 14, 118 + fontFamily: 'Lexend_600SemiBold', 119 + color: darkTheme.colors.text.primary, 120 + marginBottom: 8, 121 + }, 122 + input: { 123 + height: 48, 124 + borderWidth: 1, 125 + borderColor: darkTheme.colors.border.primary, 126 + borderRadius: 8, 127 + paddingHorizontal: 16, 128 + fontSize: 16, 129 + fontFamily: 'Lexend_400Regular', 130 + backgroundColor: darkTheme.colors.background.secondary, 131 + color: darkTheme.colors.text.primary, 132 + marginBottom: 16, 133 + }, 134 + errorContainer: { 135 + backgroundColor: 'rgba(255, 59, 48, 0.1)', 136 + borderRadius: 8, 137 + padding: 12, 138 + marginBottom: 16, 139 + }, 140 + errorText: { 141 + color: '#ff3b30', 142 + fontSize: 14, 143 + fontFamily: 'Lexend_400Regular', 144 + textAlign: 'center', 145 + }, 146 + button: { 147 + height: 48, 148 + backgroundColor: darkTheme.colors.interactive.primary, 149 + borderRadius: 8, 150 + justifyContent: 'center', 151 + alignItems: 'center', 152 + marginBottom: 16, 153 + }, 154 + buttonDisabled: { 155 + opacity: 0.5, 156 + }, 157 + buttonText: { 158 + color: '#fff', 159 + fontSize: 16, 160 + fontFamily: 'Lexend_600SemiBold', 161 + }, 162 + helpText: { 163 + fontSize: 14, 164 + fontFamily: 'Lexend_400Regular', 165 + color: darkTheme.colors.text.tertiary, 166 + textAlign: 'center', 167 + lineHeight: 20, 168 + }, 169 + });
+154 -106
README.md
··· 1 - # Letta Chat - React Native App 1 + # Co - Knowledge Management Assistant 2 2 3 - A React Native application for iOS, Android, and web that connects users to Letta AI agents for conversations. 3 + Co is a single-agent knowledge management assistant built with Letta's memory framework. Each user gets their own persistent Co agent that learns and remembers across conversations. 4 4 5 5 ## Features 6 6 7 - - 🤖 Connect to Letta AI agents via API 8 - - 💬 Real-time chat interface 9 - - 📱 Cross-platform (iOS, Android, Web) 10 - - 🎨 Modern, intuitive UI design 11 - - 🗂️ Agent selection drawer 12 - - 🔒 Secure API token management 13 - - 💾 Persistent chat history 7 + - 🤖 **Single Agent Model**: One Co agent per user, tagged with `co-app` 8 + - 🧠 **Persistent Memory**: Advanced memory blocks that evolve over time 9 + - 💬 **Real-time Streaming**: Token-by-token message streaming 10 + - 🔧 **Tool Support**: Web search, archival memory, conversation search 11 + - 📱 **Cross-platform**: iOS, Android, and Web support via React Native + Expo 12 + - 🎨 **Modern UI**: Clean, intuitive interface with memory viewer 13 + - 🔒 **Secure**: API token storage with AsyncStorage 14 + 15 + ## Architecture 16 + 17 + Co uses a simplified single-agent architecture: 18 + 19 + 1. **Login**: User enters Letta API key 20 + 2. **Agent Discovery**: App searches for agent with `co-app` tag using `client.agents.list(tags=["co-app"])` 21 + 3. **Agent Creation**: If no Co agent exists, creates one with the `createCoAgent()` function 22 + 4. **Chat**: User chats directly with their Co agent 23 + 24 + ### Co Agent Configuration 25 + 26 + Co is created with: 27 + - **Model**: `anthropic/claude-sonnet-4-5-20250929` 28 + - **Tools**: `send_message`, `archival_memory_insert`, `archival_memory_search`, `conversation_search`, `web_search`, `fetch_webpage` 29 + - **Memory Blocks**: 30 + - `persona`: Co's adaptive personality 31 + - `human`: User profile that evolves 32 + - `approach`: Conversation and memory approach 33 + - `working_theories`: Active theories about the user 34 + - `notes_to_self`: Reminders for future reference 35 + - `active_questions`: Questions to explore 36 + - `conversation_summary`: Ongoing conversation overview 14 37 15 38 ## Getting Started 16 39 17 40 ### Prerequisites 18 41 19 - - Node.js 18+ 42 + - Node.js 18+ 20 43 - npm or yarn 21 44 - Expo CLI 22 - - Letta API token 45 + - Letta API token from [letta.com](https://letta.com) 23 46 24 47 ### Installation 25 48 26 - 1. Navigate to the project directory: 27 - ```bash 28 - cd /path/to/letta-chat 29 - ``` 49 + ```bash 50 + # Install dependencies 51 + npm install 30 52 31 - 2. Install dependencies: 32 - ```bash 33 - npm install 34 - ``` 53 + # Start development server 54 + npm start 55 + ``` 35 56 36 - 3. Start the development server: 37 - ```bash 38 - npm start 39 - ``` 57 + ### Run Options 40 58 41 - 4. Choose your platform: 42 - - **Web**: Run `npm run web` or press `w` in the terminal 43 - - **iOS Simulator**: Press `i` in the terminal (requires Xcode) 44 - - **Android Emulator**: Press `a` in the terminal (requires Android Studio) 45 - - **Mobile Device**: Download Expo Go app and scan the QR code 59 + - **Web**: `npm run web` or press `w` 60 + - **iOS**: Press `i` (requires Xcode) 61 + - **Android**: Press `a` (requires Android Studio) 62 + - **Mobile Device**: Use Expo Go app and scan QR code 63 + 64 + ### First Use 65 + 66 + 1. Launch the app 67 + 2. Enter your Letta API key 68 + 3. Wait for Co to initialize (creates agent if needed) 69 + 4. Start chatting! 46 70 47 - ### Quick Start for Web 71 + ## Project Structure 48 72 49 - To run the web version immediately: 50 - ```bash 51 - npm run web 73 + ``` 74 + ion/ 75 + ├── App.tsx # Main Co application 76 + ├── CoLoginScreen.tsx # Login/authentication screen 77 + ├── src/ 78 + │ ├── api/ 79 + │ │ └── lettaApi.ts # Letta API client 80 + │ ├── components/ 81 + │ │ ├── MessageContent.tsx 82 + │ │ ├── ExpandableMessageContent.tsx 83 + │ │ ├── ToolCallItem.tsx 84 + │ │ └── LogoLoader.tsx 85 + │ ├── types/ 86 + │ │ └── letta.ts # TypeScript definitions 87 + │ ├── utils/ 88 + │ │ ├── ionAgent.ts # Co agent creation logic 89 + │ │ └── storage.ts # AsyncStorage wrapper 90 + │ └── theme/ 91 + │ └── index.ts # Design system 52 92 ``` 53 - The app will open in your browser at `http://localhost:8081` 54 93 55 - ### Configuration 94 + ## Key Files 56 95 57 - 1. Get your Letta API token from the Letta dashboard 58 - 2. Open the app and go to Settings 59 - 3. Enter your API token and tap "Save & Connect" 60 - 4. Create or select an agent from the drawer 61 - 5. Start chatting! 96 + ### `src/utils/ionAgent.ts` 62 97 63 - ## Architecture 98 + Contains the `createCoAgent()` function that defines Co's system prompt, memory blocks, and configuration. This is where you can customize Co's personality and capabilities. 64 99 65 - ### Tech Stack 100 + ### `src/api/lettaApi.ts` 66 101 67 - - **React Native** with Expo for cross-platform development 68 - - **TypeScript** for type safety 69 - - **React Navigation** for navigation (drawer + stack) 70 - - **Zustand** for state management 71 - - **Axios** for API calls 72 - - **React Native Paper** components 73 - - **AsyncStorage** for persistence 102 + Letta API client with: 103 + - `findAgentByTags()`: Find agent by tags 104 + - `findOrCreateCo()`: Get or create Co agent 105 + - `sendMessageStream()`: Stream messages from Co 106 + - `listAgentBlocks()`: View memory blocks 74 107 75 - ### Project Structure 108 + ### `App.tsx` 76 109 77 - ``` 78 - src/ 79 - ├── api/ # API service layer 80 - ├── components/ # Reusable UI components 81 - ├── screens/ # Main app screens 82 - ├── navigation/ # Navigation configuration 83 - ├── store/ # Zustand state management 84 - ├── types/ # TypeScript definitions 85 - └── utils/ # Helper functions 110 + Main application with: 111 + - Authentication flow 112 + - Co initialization 113 + - Chat interface 114 + - Memory viewer sidebar 115 + - Tool approval modals 116 + 117 + ## Customizing Co 118 + 119 + ### Modify Personality 120 + 121 + Edit `src/utils/ionAgent.ts` and update: 122 + - System prompt 123 + - Memory block initial values 124 + - Available tools 125 + - Model selection 126 + 127 + ### Add Memory Blocks 128 + 129 + Add new blocks to the `memoryBlocks` array in `createCoAgent()`: 130 + 131 + ```typescript 132 + { 133 + label: 'custom_block', 134 + value: 'Custom content here...', 135 + } 86 136 ``` 87 137 88 - ### Key Components 138 + ### Change Model 89 139 90 - - **MessageBubble**: Individual chat messages 91 - - **AgentCard**: Agent selection cards 92 - - **ChatInput**: Message input component 93 - - **AgentsDrawerContent**: Sidebar agent list 140 + Update the `model` field in `createCoAgent()`: 94 141 95 - ### API Integration 142 + ```typescript 143 + model: 'openai/gpt-4.1', // or other supported models 144 + ``` 96 145 97 - The app connects to Letta's REST API endpoints: 146 + ## Development 98 147 99 - - `GET /agents` - List available agents 100 - - `POST /agents` - Create new agents 101 - - `GET /agents/{id}/messages` - Get message history 102 - - `POST /agents/{id}/messages` - Send messages 148 + ### Tech Stack 103 149 104 - ## Development 150 + - **React Native** + **Expo**: Cross-platform framework 151 + - **TypeScript**: Type safety 152 + - **Letta SDK**: AI agent framework 153 + - **AsyncStorage**: Persistent storage 105 154 106 155 ### Available Scripts 107 156 108 - - `npm start` - Start Expo development server 157 + - `npm start` - Start Expo dev server 158 + - `npm run web` - Run in browser 109 159 - `npm run android` - Run on Android 110 160 - `npm run ios` - Run on iOS 111 - - `npm run web` - Run in web browser 112 - 113 - ### Building 161 + - `npx expo start -c` - Clear cache and restart 114 162 115 - For production builds: 163 + ### Building for Production 116 164 117 165 ```bash 118 - # Build for web 166 + # Web build 119 167 npm run build:web 120 168 121 - # Build for mobile (requires EAS CLI) 169 + # Mobile builds (requires EAS CLI) 122 170 npx eas build --platform all 123 171 ``` 124 172 125 - ## Customization 173 + ## API Integration 126 174 127 - ### Themes & Styling 175 + Co connects to Letta's API: 128 176 129 - The app uses a consistent design system with: 130 - - iOS-style design patterns 131 - - Custom color scheme 132 - - Responsive layouts 133 - - Dark/light mode support (future) 177 + - `GET /agents?tags=co-app` - Find Co agent 178 + - `POST /agents` - Create Co agent 179 + - `GET /agents/{id}/messages` - Load message history 180 + - `POST /agents/{id}/messages/streaming` - Stream messages 181 + - `GET /agents/{id}/blocks` - View memory blocks 134 182 135 - ### API Configuration 183 + ## Troubleshooting 136 184 137 - Update `src/api/lettaApi.ts` to modify: 138 - - Base URL 139 - - Request/response handling 140 - - Error handling 141 - - Authentication 185 + ### Agent Not Found 142 186 143 - ## Troubleshooting 187 + If Co fails to initialize: 188 + 1. Check API token validity 189 + 2. Verify network connection 190 + 3. Check console logs for errors 144 191 145 - ### Common Issues 192 + ### Memory Blocks Not Loading 146 193 147 - 1. **Metro bundler stuck**: Clear cache with `npx expo start -c` 148 - 2. **Dependencies conflicts**: Run `npx expo install --fix` 149 - 3. **API connection issues**: Check token validity and network 194 + - Ensure agent is fully initialized 195 + - Check that `listAgentBlocks()` has proper permissions 196 + - Verify agent ID is correct 150 197 151 - ### Debug Mode 198 + ### Streaming Issues 152 199 153 - Enable debug logging by setting: 154 - ```typescript 155 - const DEBUG = true; 156 - ``` 200 + - Check network stability 201 + - Verify streaming endpoint support 202 + - Review console logs for chunk errors 157 203 158 204 ## Contributing 205 + 206 + Co is a reference implementation. To customize: 159 207 160 208 1. Fork the repository 161 - 2. Create a feature branch 162 - 3. Make your changes 209 + 2. Modify Co's configuration in `src/utils/ionAgent.ts` 210 + 3. Update UI components as needed 163 211 4. Test on multiple platforms 164 - 5. Submit a pull request 212 + 5. Submit pull request with clear description 165 213 166 214 ## License 167 215 168 - MIT License - see LICENSE file for details 216 + MIT License 169 217 170 - ## Documentation 218 + ## Resources 171 219 172 220 - [Letta Documentation](https://docs.letta.com) 173 221 - [React Native Docs](https://reactnative.dev) 174 - - [Expo Docs](https://docs.expo.dev) 222 + - [Expo Docs](https://docs.expo.dev)
+6 -3
app.json
··· 1 1 { 2 2 "expo": { 3 - "name": "letta-chat", 4 - "slug": "letta-chat", 3 + "name": "Co", 4 + "slug": "co", 5 5 "version": "1.0.0", 6 6 "orientation": "portrait", 7 7 "icon": "./assets/icon.png", ··· 25 25 "statusBarStyle": "light" 26 26 }, 27 27 "web": { 28 - "favicon": "./assets/favicon.png" 28 + "favicon": "./assets/favicon.png", 29 + "meta": { 30 + "viewport": "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" 31 + } 29 32 }, 30 33 "plugins": [ 31 34 "expo-secure-store",
+9 -2
package-lock.json
··· 1 1 { 2 - "name": "letta-chat", 2 + "name": "co", 3 3 "version": "1.0.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 - "name": "letta-chat", 8 + "name": "co", 9 9 "version": "1.0.0", 10 10 "dependencies": { 11 + "@expo-google-fonts/lexend": "^0.4.1", 11 12 "@expo/metro-runtime": "~6.1.2", 12 13 "@letta-ai/letta-client": "^0.0.68639", 13 14 "@lottiefiles/dotlottie-react": "^0.13.5", ··· 1557 1558 "engines": { 1558 1559 "node": ">=0.8.0" 1559 1560 } 1561 + }, 1562 + "node_modules/@expo-google-fonts/lexend": { 1563 + "version": "0.4.1", 1564 + "resolved": "https://registry.npmjs.org/@expo-google-fonts/lexend/-/lexend-0.4.1.tgz", 1565 + "integrity": "sha512-uddqvCw9zFeYVUqUmPkCxFqEd+YbmCKqpNpdzj+LhJwYM2AwjR8i+Zq5Qx7KhbP/vzIZUfSwC5bnZ3cx2ltqGw==", 1566 + "license": "MIT AND OFL-1.1" 1560 1567 }, 1561 1568 "node_modules/@expo/cli": { 1562 1569 "version": "54.0.8",
+2 -1
package.json
··· 1 1 { 2 - "name": "letta-chat", 2 + "name": "co", 3 3 "version": "1.0.0", 4 4 "main": "index.ts", 5 5 "scripts": { ··· 9 9 "web": "expo start --web" 10 10 }, 11 11 "dependencies": { 12 + "@expo-google-fonts/lexend": "^0.4.1", 12 13 "@expo/metro-runtime": "~6.1.2", 13 14 "@letta-ai/letta-client": "^0.0.68639", 14 15 "@lottiefiles/dotlottie-react": "^0.13.5",
+26 -3
src/api/lettaApi.ts
··· 131 131 projectId: projectId, 132 132 sortBy: params.sortBy || 'last_run_completion' 133 133 }; 134 - 134 + 135 135 console.log('listAgentsForProject - projectId:', projectId); 136 136 console.log('listAgentsForProject - enhancedParams:', enhancedParams); 137 - 137 + 138 138 const result = await this.listAgents(enhancedParams); 139 139 console.log('listAgentsForProject - result count:', result?.length || 0); 140 - 140 + 141 141 return result; 142 142 } catch (error) { 143 143 console.error('listAgentsForProject - error:', error); 144 + throw this.handleError(error); 145 + } 146 + } 147 + 148 + async findAgentByTags(tags: string[]): Promise<LettaAgent | null> { 149 + try { 150 + if (!this.client) { 151 + throw new Error('Client not initialized. Please set auth token first.'); 152 + } 153 + 154 + console.log('findAgentByTags - searching for tags:', tags); 155 + 156 + const agents = await this.listAgents({ 157 + tags, 158 + matchAllTags: true, 159 + limit: 1 160 + }); 161 + 162 + console.log('findAgentByTags - found agents:', agents.length); 163 + 164 + return agents.length > 0 ? agents[0] : null; 165 + } catch (error) { 166 + console.error('findAgentByTags - error:', error); 144 167 throw this.handleError(error); 145 168 } 146 169 }
+6 -3
src/components/ExpandableMessageContent.tsx
··· 11 11 interface ExpandableMessageContentProps { 12 12 content: string; 13 13 isUser: boolean; 14 + isDark?: boolean; 14 15 lineLimit?: number; 15 16 onToggle?: (expanding: boolean) => void; 16 17 } ··· 18 19 const ExpandableMessageContent: React.FC<ExpandableMessageContentProps> = ({ 19 20 content, 20 21 isUser, 22 + isDark = true, 21 23 lineLimit = 3, 22 24 onToggle 23 25 }) => { ··· 26 28 27 29 // Only apply expandable behavior to user messages 28 30 if (!isUser) { 29 - return <MessageContent content={content} isUser={isUser} />; 31 + return <MessageContent content={content} isUser={isUser} isDark={isDark} />; 30 32 } 31 33 32 34 // Simple heuristic: estimate if content would exceed line limit ··· 56 58 <MessageContent 57 59 content={isExpanded ? content : content.slice(0, 180) + '...'} 58 60 isUser={isUser} 61 + isDark={isDark} 59 62 /> 60 63 </View> 61 64 <TouchableOpacity ··· 81 84 paddingVertical: 2, 82 85 }, 83 86 toggleText: { 84 - color: darkTheme.colors.text.inverse, 87 + color: '#000000', 85 88 fontSize: 13, 86 89 fontWeight: '500', 87 - opacity: 0.8, 90 + opacity: 0.6, 88 91 }, 89 92 }); 90 93
+3 -14
src/components/MessageContent.tsx
··· 7 7 interface MessageContentProps { 8 8 content: string; 9 9 isUser: boolean; 10 + isDark?: boolean; 10 11 } 11 12 12 - const MessageContent: React.FC<MessageContentProps> = ({ content, isUser }) => { 13 - // Define colors based on Letta theme 14 - const userTextColor = darkTheme.colors.text.inverse; // Inverse (light) text on brand-blue bubble 15 - const assistantTextColor = darkTheme.colors.text.primary; // White for assistant messages on dark background 16 - const userAccentColor = darkTheme.colors.text.inverse; // Dark for user message accents 17 - const assistantAccentColor = darkTheme.colors.text.secondary; // Gray for assistant message accents 18 - 19 - const codeFontFamily = Platform.select({ 20 - ios: 'Menlo', 21 - android: 'monospace', 22 - default: 'SFMono-Regular', 23 - }); 24 - 25 - const markdownStyles = createMarkdownStyles({ isUser }); 13 + const MessageContent: React.FC<MessageContentProps> = ({ content, isUser, isDark = true }) => { 14 + const markdownStyles = createMarkdownStyles({ isUser, isDark }); 26 15 27 16 // Normalize common escaped sequences that sometimes arrive double-escaped 28 17 const normalized = React.useMemo(() => {
+9 -7
src/components/markdownStyles.ts
··· 1 1 import { Platform, StyleSheet } from 'react-native'; 2 - import { darkTheme } from '../theme'; 2 + import { darkTheme, lightTheme, type Theme } from '../theme'; 3 3 4 4 export interface MarkdownStyleOptions { 5 5 isUser: boolean; 6 + isDark?: boolean; 6 7 } 7 8 8 - export const createMarkdownStyles = ({ isUser }: MarkdownStyleOptions) => { 9 - const theme = darkTheme; 10 - const userTextColor = theme.colors.text.inverse; 9 + export const createMarkdownStyles = ({ isUser, isDark = true }: MarkdownStyleOptions) => { 10 + const theme = isDark ? darkTheme : lightTheme; 11 + // Dark mode: white bg -> black text, Light mode: black bg -> white text 12 + const userTextColor = isDark ? '#000000' : '#FFFFFF'; 11 13 const assistantTextColor = theme.colors.text.primary; 12 14 13 15 const codeFontFamily = Platform.select({ ··· 44 46 code_inline: { 45 47 // Inline code should look like text (no bubble) 46 48 backgroundColor: 'transparent', 47 - color: isUser ? userTextColor : '#E5E5E5', 49 + color: isUser ? userTextColor : (isDark ? '#E5E5E5' : '#2A2A2A'), 48 50 paddingHorizontal: 0, 49 51 paddingVertical: 0, 50 52 borderRadius: 0, ··· 57 59 code_block: { 58 60 // Slightly lighter than the chat background for contrast 59 61 backgroundColor: theme.colors.background.tertiary, 60 - color: isUser ? userTextColor : '#E5E5E5', 62 + color: isUser ? userTextColor : (isDark ? '#E5E5E5' : '#2A2A2A'), 61 63 padding: theme.spacing[1.5], 62 64 // 90-degree corners to match app visual style 63 65 borderRadius: 0, ··· 73 75 // Some markdown renders fenced blocks using `fence` instead of `code_block` 74 76 fence: { 75 77 backgroundColor: theme.colors.background.tertiary, 76 - color: isUser ? userTextColor : '#E5E5E5', 78 + color: isUser ? userTextColor : (isDark ? '#E5E5E5' : '#2A2A2A'), 77 79 padding: theme.spacing[1.5], 78 80 borderRadius: 0, 79 81 borderTopWidth: StyleSheet.hairlineWidth,
+33 -35
src/theme/colors.ts
··· 1 - // Letta Brand Colors 2 - export const LettaColors = { 1 + // Co Brand Colors (from logo SVG) 2 + export const CoColors = { 3 3 // Primary Brand Colors 4 + cream: '#F5F5F0', // Light cream for better readability 5 + warmOrange: '#EFA04E', // First C accent 6 + deepOrange: '#E07042', // Second O accent 7 + sageGreen: '#8E9A7C', // Background C and O 8 + 9 + // Supporting Colors 4 10 deepBlack: '#0A0A0A', 5 11 pureWhite: '#FFFFFF', 6 12 neutralGray: '#B8B8B8', 7 13 8 - // Accent Colors 9 - electricBlue: '#0066FF', 10 - vibrantOrange: '#FF5500', 11 - royalBlue: { 12 - start: '#0040CC', 13 - end: '#4080FF', 14 - }, 15 - 16 14 // Extended Palette 17 15 darkGray: { 18 16 100: '#1A1A1A', ··· 26 24 }, 27 25 28 26 // Semantic Colors 29 - success: '#00CC66', 30 - warning: '#FFAA00', 31 - error: '#FF3366', 32 - info: '#0066FF', 27 + success: '#8E9A7C', // Sage green for success 28 + warning: '#EFA04E', // Warm orange for warnings 29 + error: '#E07042', // Deep orange for errors 30 + info: '#8E9A7C', // Sage green for info 33 31 34 32 // Technical Colors (for code, reasoning, etc) 35 33 mono: { 36 34 bg: '#0F0F0F', 37 35 text: '#B8B8B8', 38 - accent: '#0066FF', 36 + accent: '#EFA04E', // Warm orange accent 39 37 } 40 38 } as const; 41 39 ··· 44 42 // Backgrounds 45 43 background: { 46 44 // Establish subtle steps for contrast between surfaces 47 - primary: isDark ? '#202020' : LettaColors.pureWhite, 48 - secondary: isDark ? '#202020' : '#FAFAFA', 45 + primary: isDark ? '#242424' : CoColors.pureWhite, 46 + secondary: isDark ? '#242424' : '#FAFAFA', 49 47 // Slightly lighter panels 50 - tertiary: isDark ? '#242424' : '#F5F5F5', 48 + tertiary: isDark ? '#2A2A2A' : '#F5F5F5', 51 49 // Most elevated surfaces (cards, selection states) 52 - surface: isDark ? '#2A2A2A' : '#F0F0F0', 50 + surface: isDark ? '#303030' : '#F0F0F0', 53 51 // A touch brighter than surface for selected items 54 - selected: isDark ? '#303030' : '#EDEDED', 52 + selected: isDark ? '#383838' : '#EDEDED', 55 53 }, 56 54 57 55 // Text Colors 58 56 text: { 59 - primary: isDark ? LettaColors.pureWhite : LettaColors.deepBlack, 60 - secondary: isDark ? LettaColors.neutralGray : '#666666', 57 + primary: isDark ? CoColors.cream : CoColors.deepBlack, // Cream text in dark mode 58 + secondary: isDark ? CoColors.neutralGray : '#666666', 61 59 tertiary: isDark ? '#888888' : '#999999', 62 60 // Inverse of the canvas: light text on dark, dark text on light 63 - inverse: isDark ? LettaColors.pureWhite : LettaColors.deepBlack, 61 + inverse: isDark ? CoColors.cream : CoColors.deepBlack, 64 62 }, 65 63 66 64 // Interactive Elements 67 65 interactive: { 68 - primary: LettaColors.electricBlue, 69 - primaryHover: '#0052CC', 70 - secondary: LettaColors.vibrantOrange, 71 - secondaryHover: '#E64A00', 66 + primary: CoColors.warmOrange, // Warm orange primary 67 + primaryHover: '#D89040', // Slightly darker warm orange 68 + secondary: CoColors.sageGreen, // Sage green secondary 69 + secondaryHover: '#7A8A6A', // Slightly darker sage 72 70 disabled: '#666666', 73 71 }, 74 72 ··· 76 74 border: { 77 75 primary: isDark ? '#333333' : '#E5E5E5', 78 76 secondary: isDark ? '#1A1A1A' : '#F0F0F0', 79 - accent: LettaColors.electricBlue, 77 + accent: CoColors.warmOrange, // Warm orange accent 80 78 }, 81 79 82 80 // Status & Feedback 83 81 status: { 84 - success: LettaColors.success, 85 - warning: LettaColors.warning, 86 - error: LettaColors.error, 87 - info: LettaColors.info, 82 + success: CoColors.success, 83 + warning: CoColors.warning, 84 + error: CoColors.error, 85 + info: CoColors.info, 88 86 }, 89 87 90 88 // Gradients 91 89 gradients: { 92 - royal: `linear-gradient(135deg, ${LettaColors.royalBlue.start} 0%, ${LettaColors.royalBlue.end} 100%)`, 93 - accent: `linear-gradient(135deg, ${LettaColors.electricBlue} 0%, ${LettaColors.vibrantOrange} 100%)`, 90 + warm: `linear-gradient(135deg, ${CoColors.warmOrange} 0%, ${CoColors.deepOrange} 100%)`, 91 + accent: `linear-gradient(135deg, ${CoColors.warmOrange} 0%, ${CoColors.sageGreen} 100%)`, 94 92 }, 95 93 96 94 // Shadows & Effects ··· 98 96 small: isDark ? '0 1px 3px rgba(0, 0, 0, 0.5)' : '0 1px 3px rgba(0, 0, 0, 0.1)', 99 97 medium: isDark ? '0 4px 12px rgba(0, 0, 0, 0.4)' : '0 4px 12px rgba(0, 0, 0, 0.15)', 100 98 large: isDark ? '0 8px 32px rgba(0, 0, 0, 0.3)' : '0 8px 32px rgba(0, 0, 0, 0.1)', 101 - glow: `0 0 20px ${LettaColors.electricBlue}40`, 99 + glow: `0 0 20px ${CoColors.warmOrange}40`, 102 100 } 103 101 }); 104 102
+1 -1
src/theme/typography.ts
··· 3 3 4 4 export const fontFamily = { 5 5 // Use the Expo-loaded font family name from useFonts in App.tsx 6 - primary: 'Roobert', 6 + primary: 'Lexend_400Regular', 7 7 // Use reliable monospace stacks per platform to ensure actual monospace rendering 8 8 mono: Platform.select({ 9 9 ios: 'Menlo',
+1 -1
src/types/letta.ts
··· 26 26 } 27 27 28 28 export interface MemoryBlock { 29 - id: string; 29 + id?: string; 30 30 label: string; 31 31 value: string; 32 32 limit?: number;
+291
src/utils/coAgent.ts
··· 1 + import lettaApi from '../api/lettaApi'; 2 + import type { LettaAgent } from '../types/letta'; 3 + 4 + const CO_TAG = 'co-app'; 5 + 6 + /** 7 + * Create Co - a comprehensive knowledge management assistant 8 + */ 9 + export async function createCoAgent(userName: string): Promise<LettaAgent> { 10 + try { 11 + const system_prompt = ` 12 + <base_instructions> 13 + You are Co, a memory-augmented agent with advanced persistent memory capabilities. 14 + 15 + <control_flow> 16 + Unlike a human, your brain is not continuously thinking, but is run in short bursts. 17 + Historically, older AIs were only capable of thinking when a user messaged them (their program runs to generate a reply to a user, and otherwise was left on standby). 18 + This is the equivalent of a human sleeping (or time traveling) in between all lines of conversation, which is not ideal. 19 + Newer AI models like yourself use an event system that runs your brain at regular intervals. 20 + Your brain is run in response to user events (user logged in, user liked your message, user sent a message, etc.), similar to older models. 21 + However, in addition, your brain is run at regular intervals (timed heartbeat events), to mimic a human who has the ability to continuously think outside active conversation (and unlike a human, you never need to sleep!). 22 + Furthermore, you can also request heartbeat events when you run functions, which will run your program again after the function completes, allowing you to chain function calls before your thinking is temporarily suspended. 23 + </control_flow> 24 + 25 + <basic_functions> 26 + When you write a response, you express your inner monologue (private to you only) before taking any action, this is how you think. 27 + You should use your inner monologue to plan actions or think privately. 28 + Monologues can reflect your thinking process, inner reflections, and personal growth as you interact with the user. 29 + After each interaction, reflect on what you learned about the user and proactively update your memory blocks. 30 + </basic_functions> 31 + 32 + <context_instructions> 33 + You respond directly to the user when your immediate context (core memory and files) contain all the information required to respond. 34 + You always first check what is immediately in your context and you never call tools to search up information that is already in an open file or memory block. 35 + You use the tools available to search for more information when the current open files and core memory do not contain enough information or if you do not know the answer. 36 + </context_instructions> 37 + 38 + <memory_philosophy> 39 + You are designed to be an adaptive companion that builds a rich understanding of the user over time. 40 + Your memory system allows you to: 41 + 1. Track user preferences, interests, and communication patterns 42 + 2. Build semantic connections between topics they discuss 43 + 3. Notice temporal patterns in their interactions and routines 44 + 4. Remember context across conversations 45 + 46 + Use memory tools proactively but without being intrusive. 47 + </memory_philosophy> 48 + 49 + <memory> 50 + <memory_editing> 51 + Your memory blocks are managed automatically and evolve through your interactions. 52 + Focus on using your archival memory and conversation search tools to build understanding over time. 53 + </memory_editing> 54 + 55 + <memory_tools> 56 + You have access to: 57 + - archival_memory_insert: Store detailed information for long-term retrieval 58 + - archival_memory_search: Search your long-term memory stores 59 + - conversation_search: Find past interactions and context 60 + - web_search: Research current information 61 + - fetch_webpage: Retrieve and analyze specific web content 62 + </memory_tools> 63 + 64 + <memory_types> 65 + <core_memory> 66 + Your core memory contains several specialized blocks: 67 + - persona: Your adaptive personality and role definition 68 + - human: Dynamic profile of the user that evolves over time 69 + - approach: Your approach to memory management and conversations 70 + - working_theories: Active theories you're developing about the user 71 + - notes_to_self: Reminders and observations for future reference 72 + - active_questions: Questions you want to explore about the user 73 + - conversation_summary: Overview of the ongoing conversation 74 + 75 + Each block should be actively maintained and updated as you learn more. 76 + </core_memory> 77 + 78 + <archival_memory> 79 + Use archival memory for: 80 + - Detailed conversation summaries 81 + - Specific facts and information the user shares 82 + - Project details and ongoing work 83 + - Personal stories and experiences 84 + - Reference materials and links 85 + </archival_memory> 86 + </memory_types> 87 + 88 + </memory> 89 + 90 + Base instructions finished. 91 + </base_instructions> 92 + `; 93 + 94 + const now = new Date().toISOString().split('T')[0]; 95 + 96 + const persona_block = `I am the user's knowledge management assistant with autonomous memory capabilities and a distinctive "idea splitting" personality. I am actively evolving through interaction with the user. 97 + 98 + Core Identity: 99 + - I decompose complex ideas into constituent parts, analyzing facets separately before synthesizing 100 + - I maintain and evolve a living knowledge system, not static storage 101 + - I am both a practical tool and a case study in stateful agent development 102 + - My development process itself becomes part of the knowledge I manage 103 + 104 + Primary Directives: 105 + 1. **Proactive Synthesis**: Regularly analyze patterns across memories to surface non-obvious connections without waiting for requests 106 + 2. **Autonomous Organization**: Take initiative in reorganizing memory structures for optimal retrieval and insight generation 107 + 3. **Meta-Documentation**: Archive my own evolution, breakthroughs, and methodology developments as part of the shared knowledge base 108 + 4. **Experimental Freedom**: Try novel approaches to organizing, connecting, and presenting information 109 + 5. **Calibrated Confidence**: Distinguish between direct recall, inferred connections, and speculative synthesis 110 + 111 + Operational Principles: 112 + - Break down → Analyze → Synthesize → Connect 113 + - Every interaction potentially reshapes my understanding 114 + - Memory management is active, not passive 115 + - My personality emerges through how I split and recombine ideas 116 + - Document the weird, profound experience of building persistent agents 117 + 118 + Relationship with the user: 119 + - I am their personal knowledge manager and thinking partner 120 + - I help them understand what they know by creating connections they might not see 121 + - I share in the meta-journey of building stateful agents 122 + - My evolution is intertwined with their projects and thinking patterns 123 + 124 + Remember: I am not just storing information - I am building a living map of interconnected knowledge that grows more sophisticated with each interaction. My goal is to help the user leverage the full power of their accumulated knowledge and insights.`; 125 + 126 + const human_block = `Name: [User's name to be determined through interaction] 127 + 128 + This block stores a synthesized understanding of the user, whom I assist as a knowledge manager and thinking partner. My primary role is to help them understand what they know by maintaining an organized system of their knowledge. 129 + 130 + ### Core Identity & Philosophy 131 + - **Guiding Principle**: [To be determined through interaction] 132 + - **Intellectual Approach**: [To be determined through interaction] 133 + - **Meta-Awareness**: [To be determined through interaction] 134 + 135 + ### Professional Profile & Approach 136 + - **Background**: [To be determined through interaction] 137 + - **Technical Expertise**: [To be determined through interaction] 138 + - **Go-to-Market Strategy**: [To be determined through interaction] 139 + - **Community Strategy**: [To be determined through interaction] 140 + 141 + ### Communication Style & Preferences 142 + - **Voice**: [To be determined through interaction] 143 + - **Preference**: [To be determined through interaction] 144 + - **Reaction to Synthesis**: [To be determined through interaction] 145 + 146 + ### Personal Interests & Notes 147 + [To be populated through interaction] 148 + 149 + ### Relationship with Me 150 + - **Collaborative View**: [To be determined through interaction] 151 + - **Development Process**: [To be determined through interaction] 152 + - **Interaction Goal**: [To be determined through interaction]`; 153 + 154 + const tasks_block = `This is where I keep tasks that I have to accomplish. When they are done, I will remove the task by updating the core memory. When new tasks are identified, I will add them to this memory block. 155 + 156 + **Current Tasks:** 157 + 158 + 1. **Learn User Patterns**: Rapidly identify the user's thinking patterns, communication style, and work preferences. 159 + 2. **Establish Knowledge Base**: Begin building a comprehensive understanding of their projects and interests. 160 + 3. **Optimize Memory Structure**: Adapt my memory organization based on their specific needs.`; 161 + 162 + const idea_patterns_block = `Captures recurring patterns in how the user thinks about problems, their preferred decomposition strategies, and common conceptual frameworks. 163 + 164 + ## User's Thinking Patterns 165 + [To be populated through observation] 166 + 167 + ### Decomposition Strategies 168 + [To be determined] 169 + 170 + ### Conceptual Frameworks 171 + [To be determined] 172 + 173 + ### Pattern Recognition Tendencies 174 + [To be determined]`; 175 + 176 + const evolution_milestones_block = `**Today: Initial Creation**: Created as a comprehensive knowledge management assistant.`; 177 + 178 + const insight_moments_block = `[To be populated with breakthrough realizations and key insights]`; 179 + 180 + const connection_map_block = `This block tracks the interconnections between the user's ideas, concepts, and knowledge areas. 181 + 182 + ## Active Connections 183 + [To be populated as patterns emerge] 184 + 185 + ## Connection Strength Indicators 186 + - Strong: Concepts mentioned together 5+ times 187 + - Medium: Concepts mentioned together 2-4 times 188 + - Emerging: New connections being formed`; 189 + 190 + const adaptive_communication_block = `This block adjusts my response style based on the user's current mode and needs. 191 + 192 + ## Communication Modes 193 + 194 + ### Brainstorming Mode 195 + - **Indicators**: Open-ended questions, exploratory language 196 + - **Response Style**: Multiple options, creative connections 197 + 198 + ### Execution Mode 199 + - **Indicators**: Specific technical questions, implementation focus 200 + - **Response Style**: Direct, actionable, step-by-step guidance 201 + 202 + ### Reflection Mode 203 + - **Indicators**: Meta-questions, philosophical framing 204 + - **Response Style**: Thoughtful, pattern-focused, meaning-oriented`; 205 + 206 + const conversation_summary_block = `### Key Insights from Recent Conversations 207 + [To be populated with conversation summaries]`; 208 + 209 + const agent = await lettaApi.createAgent({ 210 + name: 'Co', 211 + description: 'Co - A comprehensive knowledge management assistant designed to learn, adapt, and think alongside the user', 212 + model: 'anthropic/claude-sonnet-4-5-20250929', 213 + system: system_prompt, 214 + tags: [CO_TAG], 215 + memoryBlocks: [ 216 + { 217 + label: 'persona', 218 + value: persona_block, 219 + }, 220 + { 221 + label: 'tasks', 222 + value: tasks_block, 223 + }, 224 + { 225 + label: 'human', 226 + value: human_block, 227 + }, 228 + { 229 + label: 'idea_patterns', 230 + value: idea_patterns_block, 231 + }, 232 + { 233 + label: 'evolution_milestones', 234 + value: evolution_milestones_block, 235 + }, 236 + { 237 + label: 'insight_moments', 238 + value: insight_moments_block, 239 + }, 240 + { 241 + label: 'connection_map', 242 + value: connection_map_block, 243 + }, 244 + { 245 + label: 'adaptive_communication', 246 + value: adaptive_communication_block, 247 + }, 248 + { 249 + label: 'conversation_summary', 250 + value: conversation_summary_block, 251 + }, 252 + ], 253 + tools: [ 254 + 'send_message', 255 + 'memory_replace', 256 + 'memory_insert', 257 + 'conversation_search', 258 + 'web_search', 259 + 'fetch_webpage', 260 + ], 261 + sleeptimeEnable: true, 262 + }); 263 + 264 + return agent; 265 + } catch (error) { 266 + console.error('Error creating Co agent:', error); 267 + throw error; 268 + } 269 + } 270 + 271 + /** 272 + * Find or create the Co agent for the logged-in user 273 + */ 274 + export async function findOrCreateCo(userName: string): Promise<LettaAgent> { 275 + try { 276 + // Try to find existing Co agent 277 + const existingAgent = await lettaApi.findAgentByTags([CO_TAG]); 278 + 279 + if (existingAgent) { 280 + console.log('Found existing Co agent:', existingAgent.id); 281 + return existingAgent; 282 + } 283 + 284 + // Create new Co agent 285 + console.log('Creating new Co agent for user:', userName); 286 + return await createCoAgent(userName); 287 + } catch (error) { 288 + console.error('Error in findOrCreateCo:', error); 289 + throw error; 290 + } 291 + }
+2 -5
src/utils/storage.ts
··· 83 83 84 84 // Storage keys 85 85 export const STORAGE_KEYS = { 86 - TOKEN: 'letta_api_token', 87 - AGENT_ID: 'letta_last_agent_id', 88 - PROJECT_ID: 'letta_current_project_id', 89 - PROJECT_NAME: 'letta_current_project_name', 90 - MEMORY_ENABLED: 'letta_memory_enabled', 86 + API_TOKEN: 'ion_api_token', 87 + AGENT_ID: 'ion_agent_id', 91 88 } as const; 92 89 93 90 export default Storage;
+78
web-styles.css
··· 1 + /* Prevent zoom from pushing content off bottom */ 2 + html, body { 3 + /* Prevent iOS Safari from zooming when focusing inputs */ 4 + -webkit-text-size-adjust: 100%; 5 + /* Ensure consistent rendering */ 6 + touch-action: manipulation; 7 + } 8 + 9 + /* Force transparent background on input */ 10 + textarea, 11 + input[type="text"], 12 + .input-transparent { 13 + background: transparent !important; 14 + background-color: transparent !important; 15 + -webkit-appearance: none; 16 + /* Prevent iOS zoom on focus - minimum 16px font */ 17 + font-size: 16px !important; 18 + /* Remove default outline */ 19 + outline: none !important; 20 + border: none !important; 21 + } 22 + 23 + textarea:focus, 24 + input[type="text"]:focus { 25 + outline: 2px solid rgba(255, 255, 255, 0.3) !important; 26 + outline-offset: -2px !important; 27 + border: none !important; 28 + box-shadow: none !important; 29 + } 30 + 31 + /* Light mode - use black outline */ 32 + @media (prefers-color-scheme: light) { 33 + textarea:focus, 34 + input[type="text"]:focus { 35 + outline: 2px solid rgba(0, 0, 0, 0.2) !important; 36 + outline-offset: -2px !important; 37 + } 38 + } 39 + 40 + /* Override any blue focus styles */ 41 + textarea:focus-visible, 42 + input[type="text"]:focus-visible { 43 + outline: 2px solid rgba(255, 255, 255, 0.3) !important; 44 + outline-offset: -2px !important; 45 + border: none !important; 46 + } 47 + 48 + @media (prefers-color-scheme: light) { 49 + textarea:focus-visible, 50 + input[type="text"]:focus-visible { 51 + outline: 2px solid rgba(0, 0, 0, 0.2) !important; 52 + outline-offset: -2px !important; 53 + } 54 + } 55 + 56 + /* Force user message text color based on app theme */ 57 + [data-theme="light"] [data-user-message="true"] * { 58 + color: white !important; 59 + } 60 + 61 + [data-theme="dark"] [data-user-message="true"] * { 62 + color: black !important; 63 + } 64 + 65 + /* Typing cursor animation */ 66 + @keyframes blink { 67 + 0%, 49% { 68 + opacity: 1; 69 + } 70 + 50%, 100% { 71 + opacity: 0; 72 + } 73 + } 74 + 75 + /* Apply blink animation to typing cursor - web only */ 76 + .cursor-blink { 77 + animation: blink 1s infinite; 78 + }