Skip to Content
Mobile AppLuna Chat Interface

Luna Chat Interface

The mobile Luna AI chat interface provides seamless access to Loop Health’s intelligent health advisor with streaming responses, persistent chat history, and context-aware suggestions — all optimized for mobile interaction patterns.

Overview

The mobile Luna chat delivers the full power of Loop Health’s AI advisor in a mobile-optimized interface:

  • Streaming Responses — Real-time AI responses via Server-Sent Events
  • Chat History Persistence — Conversations saved across app sessions
  • Suggested Prompts — Context-aware quick actions based on user data
  • Voice Input — Speech-to-text for hands-free interaction
  • Rich Media Support — Images, charts, and formatted responses
  • Offline Message Queue — Messages sent when connection restored

Architecture

Component Structure

LunaChat (components/LunaChat.tsx) ├── MessageList (components/MessageList.tsx) │ ├── MessageBubble (components/MessageBubble.tsx) │ ├── TypingIndicator (components/TypingIndicator.tsx) │ └── DateSeparator (components/DateSeparator.tsx) ├── MessageInput (components/MessageInput.tsx) │ ├── TextInput (native) │ ├── VoiceButton (components/VoiceButton.tsx) │ └── SendButton (components/SendButton.tsx) ├── SuggestedPrompts (components/SuggestedPrompts.tsx) └── ChatHeader (components/ChatHeader.tsx)

State Management

interface ChatState { messages: Message[]; isStreaming: boolean; isTyping: boolean; currentSessionId: string; suggestedPrompts: SuggestedPrompt[]; voiceInputActive: boolean; connectionStatus: 'connected' | 'disconnected' | 'reconnecting'; } interface Message { id: string; content: string; role: 'user' | 'assistant' | 'system'; timestamp: Date; sessionId: string; metadata?: { toolCalls?: ToolCall[]; streamComplete?: boolean; voiceInput?: boolean; }; }

Streaming Implementation

Vercel AI SDK Integration

import { useChat } from 'ai/react'; const LunaChat = () => { const { messages, input, handleInputChange, handleSubmit, isLoading, error, } = useChat({ api: '/api/luna/chat', headers: { Authorization: `Bearer ${clerkToken}`, }, onResponse: (response) => { // Handle streaming start setIsStreaming(true); }, onFinish: (message) => { // Handle streaming complete setIsStreaming(false); saveChatHistory(message); updateSuggestedPrompts(message); }, onError: (error) => { // Handle streaming errors showErrorToast(error.message); }, }); return ( <View style={styles.container}> <MessageList messages={messages} isStreaming={isStreaming} /> <SuggestedPrompts prompts={suggestedPrompts} onSelect={handlePromptSelect} /> <MessageInput value={input} onChange={handleInputChange} onSubmit={handleSubmit} disabled={isLoading} /> </View> ); };

Real-time Message Display

const MessageBubble = ({ message, isStreaming }: MessageBubbleProps) => { const [displayText, setDisplayText] = useState(''); useEffect(() => { if (message.role === 'assistant' && isStreaming) { // Animate text appearance for streaming messages let currentIndex = 0; const interval = setInterval(() => { if (currentIndex < message.content.length) { setDisplayText(message.content.slice(0, currentIndex + 1)); currentIndex++; } else { clearInterval(interval); } }, 20); // 20ms per character for smooth animation return () => clearInterval(interval); } else { setDisplayText(message.content); } }, [message.content, isStreaming]); return ( <View style={[styles.bubble, message.role === 'user' ? styles.userBubble : styles.assistantBubble]}> <Text style={styles.messageText}>{displayText}</Text> {isStreaming && <TypingIndicator />} </View> ); };

Chat History Persistence

AsyncStorage Integration

const CHAT_HISTORY_KEY = '@luna_chat_history'; const MAX_STORED_MESSAGES = 1000; const saveChatHistory = async (messages: Message[]) => { try { // Keep only recent messages to avoid storage bloat const recentMessages = messages.slice(-MAX_STORED_MESSAGES); await AsyncStorage.setItem( CHAT_HISTORY_KEY, JSON.stringify({ messages: recentMessages, lastUpdated: new Date().toISOString(), }) ); } catch (error) { console.error('Failed to save chat history:', error); } }; const loadChatHistory = async (): Promise<Message[]> => { try { const stored = await AsyncStorage.getItem(CHAT_HISTORY_KEY); if (!stored) return []; const { messages } = JSON.parse(stored); return messages.map((msg: any) => ({ ...msg, timestamp: new Date(msg.timestamp), })); } catch (error) { console.error('Failed to load chat history:', error); return []; } };

Session Management

const generateSessionId = () => `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const startNewSession = () => { const sessionId = generateSessionId(); setChatState(prev => ({ ...prev, currentSessionId: sessionId, suggestedPrompts: getInitialPrompts(), })); // Analytics tracking analytics.track('luna_chat_session_started', { sessionId, userId: user.id, platform: 'mobile', }); };

Suggested Prompts

Context-Aware Suggestions

interface SuggestedPrompt { id: string; text: string; category: 'health' | 'labs' | 'protocols' | 'supplies' | 'general'; priority: number; conditions?: { hasRecentLabs?: boolean; hasActiveProtocols?: boolean; hasLowSupplies?: boolean; }; } const generateSuggestedPrompts = async (userContext: UserContext): Promise<SuggestedPrompt[]> => { const prompts: SuggestedPrompt[] = []; // Recent lab results available if (userContext.hasRecentLabs) { prompts.push({ id: 'analyze-labs', text: 'Analyze my latest lab results', category: 'labs', priority: 10, }); } // Active protocols if (userContext.activeProtocols.length > 0) { prompts.push({ id: 'protocol-check', text: 'How am I doing with my current protocol?', category: 'protocols', priority: 8, }); } // Low supply alerts if (userContext.lowSupplyItems.length > 0) { prompts.push({ id: 'reorder-supplies', text: `I need to reorder ${userContext.lowSupplyItems[0]}`, category: 'supplies', priority: 9, }); } // General health questions prompts.push( { id: 'sleep-optimization', text: 'How can I improve my sleep quality?', category: 'health', priority: 5, }, { id: 'energy-levels', text: 'Why am I feeling low energy lately?', category: 'health', priority: 4, } ); // Sort by priority and return top 4 return prompts.sort((a, b) => b.priority - a.priority).slice(0, 4); };

Prompt Selection Handling

const SuggestedPrompts = ({ prompts, onSelect }: SuggestedPromptsProps) => { const handlePromptSelect = (prompt: SuggestedPrompt) => { // Analytics tracking analytics.track('luna_suggested_prompt_selected', { promptId: prompt.id, category: prompt.category, text: prompt.text, }); // Send message onSelect(prompt.text); // Clear suggestions after selection setSuggestedPrompts([]); }; return ( <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.promptsContainer}> {prompts.map(prompt => ( <TouchableOpacity key={prompt.id} style={styles.promptButton} onPress={() => handlePromptSelect(prompt)} > <Text style={styles.promptText}>{prompt.text}</Text> </TouchableOpacity> ))} </ScrollView> ); };

Voice Input Integration

Speech-to-Text Setup

import { Audio } from 'expo-av'; import * as Speech from 'expo-speech'; const VoiceInput = ({ onTranscript, disabled }: VoiceInputProps) => { const [isRecording, setIsRecording] = useState(false); const [recording, setRecording] = useState<Audio.Recording | null>(null); const startRecording = async () => { try { // Request microphone permissions const { status } = await Audio.requestPermissionsAsync(); if (status !== 'granted') { Alert.alert('Permission needed', 'Please enable microphone access'); return; } // Configure audio mode await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true, }); // Start recording const { recording } = await Audio.Recording.createAsync( Audio.RecordingOptionsPresets.HIGH_QUALITY ); setRecording(recording); setIsRecording(true); // Haptic feedback Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); } catch (error) { console.error('Failed to start recording:', error); } }; const stopRecording = async () => { if (!recording) return; try { setIsRecording(false); await recording.stopAndUnloadAsync(); const uri = recording.getURI(); if (uri) { // Send audio to transcription service const transcript = await transcribeAudio(uri); onTranscript(transcript); } setRecording(null); } catch (error) { console.error('Failed to stop recording:', error); } }; return ( <TouchableOpacity style={[styles.voiceButton, isRecording && styles.voiceButtonActive]} onPressIn={startRecording} onPressOut={stopRecording} disabled={disabled} > <Icon name={isRecording ? 'mic' : 'mic-outline'} size={24} /> </TouchableOpacity> ); };

Audio Transcription

const transcribeAudio = async (audioUri: string): Promise<string> => { try { const formData = new FormData(); formData.append('audio', { uri: audioUri, type: 'audio/m4a', name: 'recording.m4a', } as any); const response = await fetch('/api/transcribe', { method: 'POST', headers: { 'Authorization': `Bearer ${clerkToken}`, 'Content-Type': 'multipart/form-data', }, body: formData, }); const { transcript } = await response.json(); return transcript; } catch (error) { console.error('Transcription failed:', error); throw new Error('Failed to transcribe audio'); } };

Rich Media Support

Message Formatting

const MessageContent = ({ message }: MessageContentProps) => { const renderContent = () => { // Handle different content types if (message.metadata?.toolCalls) { return <ToolCallResults toolCalls={message.metadata.toolCalls} />; } // Parse markdown-like formatting const parts = message.content.split(/(\*\*.*?\*\*|\*.*?\*|`.*?`)/); return ( <Text style={styles.messageText}> {parts.map((part, index) => { if (part.startsWith('**') && part.endsWith('**')) { return <Text key={index} style={styles.bold}>{part.slice(2, -2)}</Text>; } if (part.startsWith('*') && part.endsWith('*')) { return <Text key={index} style={styles.italic}>{part.slice(1, -1)}</Text>; } if (part.startsWith('`') && part.endsWith('`')) { return <Text key={index} style={styles.code}>{part.slice(1, -1)}</Text>; } return part; })} </Text> ); }; return ( <View style={styles.messageContent}> {renderContent()} {message.metadata?.charts && ( <ChartRenderer charts={message.metadata.charts} /> )} </View> ); };

Chart Integration

import { VictoryChart, VictoryLine, VictoryArea } from 'victory-native'; const ChartRenderer = ({ charts }: ChartRendererProps) => { return ( <View style={styles.chartContainer}> {charts.map((chart, index) => ( <View key={index} style={styles.chart}> <Text style={styles.chartTitle}>{chart.title}</Text> <VictoryChart theme={VictoryTheme.material} width={300} height={200} padding={{ left: 50, top: 20, right: 50, bottom: 50 }} > <VictoryLine data={chart.data} x="date" y="value" style={{ data: { stroke: "#007AFF" }, }} /> </VictoryChart> </View> ))} </View> ); };

Offline Support

Message Queue Management

interface QueuedMessage { id: string; content: string; timestamp: Date; retryCount: number; } const MessageQueue = { queue: [] as QueuedMessage[], add: (message: string) => { const queuedMessage: QueuedMessage = { id: generateId(), content: message, timestamp: new Date(), retryCount: 0, }; MessageQueue.queue.push(queuedMessage); AsyncStorage.setItem('@message_queue', JSON.stringify(MessageQueue.queue)); }, process: async () => { if (MessageQueue.queue.length === 0) return; const message = MessageQueue.queue[0]; try { await sendMessage(message.content); MessageQueue.queue.shift(); // Remove from queue AsyncStorage.setItem('@message_queue', JSON.stringify(MessageQueue.queue)); } catch (error) { message.retryCount++; if (message.retryCount >= 3) { // Remove failed message after 3 attempts MessageQueue.queue.shift(); showErrorToast('Failed to send message'); } AsyncStorage.setItem('@message_queue', JSON.stringify(MessageQueue.queue)); } }, }; // Process queue when connection restored NetInfo.addEventListener(state => { if (state.isConnected) { MessageQueue.process(); } });

Performance Optimization

Message List Virtualization

import { FlashList } from '@shopify/flash-list'; const MessageList = ({ messages }: MessageListProps) => { const renderMessage = ({ item: message, index }: { item: Message; index: number }) => ( <MessageBubble message={message} isStreaming={index === messages.length - 1 && isStreaming} /> ); return ( <FlashList data={messages} renderItem={renderMessage} estimatedItemSize={80} keyExtractor={item => item.id} inverted // Show newest messages at bottom showsVerticalScrollIndicator={false} contentContainerStyle={styles.messageList} /> ); };

Image Optimization

const OptimizedImage = ({ uri, alt }: OptimizedImageProps) => { const [loading, setLoading] = useState(true); return ( <View style={styles.imageContainer}> {loading && <ActivityIndicator style={styles.imageLoader} />} <Image source={{ uri }} style={styles.messageImage} onLoad={() => setLoading(false)} onError={() => setLoading(false)} resizeMode="contain" /> </View> ); };

Testing

Component Tests

describe('LunaChat', () => { it('should display messages correctly', () => { const messages = [ { id: '1', content: 'Hello', role: 'user' as const, timestamp: new Date() }, { id: '2', content: 'Hi there!', role: 'assistant' as const, timestamp: new Date() }, ]; const { getByText } = render(<MessageList messages={messages} />); expect(getByText('Hello')).toBeTruthy(); expect(getByText('Hi there!')).toBeTruthy(); }); it('should handle voice input', async () => { const onTranscript = jest.fn(); const { getByTestId } = render(<VoiceInput onTranscript={onTranscript} />); const voiceButton = getByTestId('voice-button'); fireEvent(voiceButton, 'pressIn'); // Mock transcription await waitFor(() => { expect(onTranscript).toHaveBeenCalledWith('Test transcript'); }); }); });

See Also