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
- Luna AI Tools — Complete tool reference for AI capabilities
- Push Notifications — Chat-related notification workflows
- Vercel AI SDK — Streaming chat implementation
- Expo Audio — Voice input recording