feat: refactor app structure and components for improved organization and functionality

- Moved components and hooks into a new directory structure under `src/app` for better organization.
- Updated the main entry point to use `index.ts` and adjusted routing accordingly.
- Introduced new components such as `ActiveProcesses`, `AgentStreamView`, and `ConnectionStatus` to enhance the app's functionality.
- Removed deprecated components and hooks to streamline the codebase.
- Updated styles to align with the new theme structure and ensure consistency across the app.

This refactor aims to improve maintainability and scalability of the application.
This commit is contained in:
Mohamed Boudra
2025-10-22 15:48:53 +02:00
parent 2d1ab5bf79
commit 89641a18e7
31 changed files with 8915 additions and 522 deletions
+14
View File
@@ -0,0 +1,14 @@
import { Stack } from 'expo-router';
import { SafeAreaProvider } from 'react-native-safe-area-context';
export default function RootLayout() {
return (
<SafeAreaProvider>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen name="audio-test" />
</Stack>
</SafeAreaProvider>
);
}
+527
View File
@@ -0,0 +1,527 @@
import { View, Text, Pressable, ScrollView, Alert } from 'react-native';
import { useState, useEffect } from 'react';
import { StyleSheet } from 'react-native-unistyles';
import { useAudioRecorder } from '../hooks/use-audio-recorder';
import { useAudioPlayer } from '../hooks/use-audio-player';
import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio';
const styles = StyleSheet.create((theme) => ({
scrollView: {
flex: 1,
backgroundColor: theme.colors.palette.white,
},
scrollViewDark: {
backgroundColor: theme.colors.background,
},
container: {
padding: theme.spacing[6],
},
title: {
fontSize: theme.fontSize['3xl'],
fontWeight: theme.fontWeight.bold,
color: theme.colors.palette.gray[900],
marginBottom: theme.spacing[2],
},
titleDark: {
color: theme.colors.foreground,
},
subtitle: {
fontSize: theme.fontSize.base,
color: theme.colors.palette.gray[600],
marginBottom: theme.spacing[8],
},
subtitleDark: {
color: theme.colors.mutedForeground,
},
section: {
marginBottom: theme.spacing[8],
},
sectionTitle: {
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.palette.gray[900],
marginBottom: theme.spacing[2],
},
sectionTitleDark: {
color: theme.colors.foreground,
},
permissionCard: {
padding: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
},
permissionGranted: {
backgroundColor: theme.colors.palette.green[100],
},
permissionGrantedDark: {
backgroundColor: theme.colors.palette.green[900],
},
permissionDenied: {
backgroundColor: theme.colors.palette.red[100],
},
permissionDeniedDark: {
backgroundColor: theme.colors.palette.red[900],
},
permissionText: {
fontSize: theme.fontSize.base,
},
permissionTextGranted: {
color: theme.colors.palette.green[800],
},
permissionTextGrantedDark: {
color: theme.colors.palette.green[200],
},
permissionTextDenied: {
color: theme.colors.palette.red[800],
},
permissionTextDeniedDark: {
color: theme.colors.palette.red[200],
},
statusCard: {
marginBottom: theme.spacing[4],
padding: theme.spacing[4],
backgroundColor: theme.colors.palette.gray[100],
borderRadius: theme.borderRadius.lg,
},
statusCardDark: {
backgroundColor: theme.colors.card,
},
statusLabel: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.palette.gray[700],
marginBottom: theme.spacing[1],
},
statusLabelDark: {
color: theme.colors.mutedForeground,
},
statusValue: {
fontSize: theme.fontSize.base,
color: theme.colors.palette.gray[900],
},
statusValueDark: {
color: theme.colors.foreground,
},
controlsRow: {
flexDirection: 'row',
gap: theme.spacing[3],
marginBottom: theme.spacing[4],
},
button: {
flex: 1,
padding: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
alignItems: 'center',
},
buttonBlue: {
backgroundColor: theme.colors.palette.blue[500],
},
buttonBlueDark: {
backgroundColor: theme.colors.palette.blue[600],
},
buttonRed: {
backgroundColor: theme.colors.palette.red[500],
},
buttonRedDark: {
backgroundColor: theme.colors.palette.red[600],
},
buttonGreen: {
backgroundColor: theme.colors.palette.green[500],
},
buttonGreenDark: {
backgroundColor: theme.colors.palette.green[600],
},
buttonPurple: {
backgroundColor: theme.colors.palette.purple[500],
},
buttonPurpleDark: {
backgroundColor: theme.colors.palette.purple[600],
},
buttonOrange: {
backgroundColor: theme.colors.palette.orange[500],
},
buttonOrangeDark: {
backgroundColor: theme.colors.palette.orange[600],
},
buttonDisabled: {
backgroundColor: theme.colors.palette.gray[300],
},
buttonDisabledDark: {
backgroundColor: theme.colors.muted,
},
buttonText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.palette.white,
},
buttonTextDisabled: {
color: theme.colors.palette.gray[500],
},
buttonTextDisabledDark: {
color: theme.colors.mutedForeground,
},
fullWidthButton: {
padding: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
alignItems: 'center',
},
infoCard: {
padding: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.palette.blue[50],
},
infoCardDark: {
backgroundColor: theme.colors.palette.blue[950],
},
infoCardLabel: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.palette.blue[700],
marginBottom: theme.spacing[1],
},
infoCardLabelDark: {
color: theme.colors.palette.blue[300],
},
infoCardValue: {
fontSize: theme.fontSize.base,
color: theme.colors.palette.blue[900],
},
infoCardValueDark: {
color: theme.colors.palette.blue[100],
},
infoCardSubtext: {
fontSize: theme.fontSize.sm,
color: theme.colors.palette.blue[600],
marginTop: theme.spacing[1],
},
infoCardSubtextDark: {
color: theme.colors.palette.blue[400],
},
configCard: {
padding: theme.spacing[4],
backgroundColor: theme.colors.palette.gray[50],
borderRadius: theme.borderRadius.lg,
},
configCardDark: {
backgroundColor: theme.colors.card,
},
configTitle: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.palette.gray[700],
marginBottom: theme.spacing[2],
},
configTitleDark: {
color: theme.colors.mutedForeground,
},
configText: {
fontSize: theme.fontSize.sm,
color: theme.colors.palette.gray[600],
marginBottom: theme.spacing[1],
},
configTextDark: {
color: theme.colors.mutedForeground,
},
gap3: {
gap: theme.spacing[3],
},
}));
export default function AudioTestScreen() {
const [permissionGranted, setPermissionGranted] = useState(false);
const [recordingStatus, setRecordingStatus] = useState<'idle' | 'recording' | 'processing'>('idle');
const [lastRecordingSize, setLastRecordingSize] = useState<number | null>(null);
const [playbackStatus, setPlaybackStatus] = useState<string>('No audio playing');
const [lastRecordedBlob, setLastRecordedBlob] = useState<Blob | null>(null);
const audioRecorder = useAudioRecorder({
sampleRate: 16000,
numberOfChannels: 1,
});
const audioPlayer = useAudioPlayer();
useEffect(() => {
checkAndRequestPermissions();
}, []);
async function checkAndRequestPermissions() {
try {
const { status } = await getRecordingPermissionsAsync();
if (status === 'granted') {
setPermissionGranted(true);
} else {
const { granted } = await requestRecordingPermissionsAsync();
setPermissionGranted(granted);
if (!granted) {
Alert.alert(
'Permission Required',
'Microphone permission is required to test audio recording.'
);
}
}
} catch (error) {
console.error('[AudioTest] Permission error:', error);
Alert.alert('Error', 'Failed to check/request microphone permissions');
}
}
async function handleStartRecording() {
if (!permissionGranted) {
Alert.alert('Permission Required', 'Please grant microphone permission first');
return;
}
try {
setRecordingStatus('recording');
setLastRecordingSize(null);
await audioRecorder.start();
} catch (error: any) {
console.error('[AudioTest] Start recording error:', error);
Alert.alert('Recording Error', error.message);
setRecordingStatus('idle');
}
}
async function handleStopRecording() {
try {
setRecordingStatus('processing');
const audioBlob = await audioRecorder.stop();
setLastRecordingSize(audioBlob.size);
setLastRecordedBlob(audioBlob);
setRecordingStatus('idle');
Alert.alert(
'Recording Complete',
`Audio recorded successfully!\nSize: ${audioBlob.size} bytes\nType: ${audioBlob.type}`
);
} catch (error: any) {
console.error('[AudioTest] Stop recording error:', error);
Alert.alert('Recording Error', error.message);
setRecordingStatus('idle');
}
}
async function handlePlayLastRecording() {
if (!lastRecordedBlob) {
Alert.alert('No Recording', 'Please record audio first');
return;
}
try {
setPlaybackStatus('Playing last recording...');
const duration = await audioPlayer.play(lastRecordedBlob);
setPlaybackStatus(`Playback complete (${duration.toFixed(2)}s)`);
} catch (error: any) {
console.error('[AudioTest] Playback error:', error);
Alert.alert('Playback Error', error.message);
setPlaybackStatus('Playback failed');
}
}
async function handlePlayTestAudio() {
// Create a test audio blob (silent audio for testing)
// In a real scenario, this would be audio from the server
try {
setPlaybackStatus('Playing test audio...');
// For now, we'll show an alert since we don't have a test audio file
Alert.alert(
'Test Audio',
'This would play a test audio file. Try recording and playing back instead.'
);
setPlaybackStatus('Test audio not implemented');
} catch (error: any) {
console.error('[AudioTest] Test playback error:', error);
Alert.alert('Playback Error', error.message);
setPlaybackStatus('Test playback failed');
}
}
function handleStopPlayback() {
audioPlayer.stop();
setPlaybackStatus('Playback stopped');
}
return (
<ScrollView style={[styles.scrollView, styles.scrollViewDark]}>
<View style={styles.container}>
{/* Header */}
<Text style={[styles.title, styles.titleDark]}>
Audio Test
</Text>
<Text style={[styles.subtitle, styles.subtitleDark]}>
Test audio recording and playback functionality
</Text>
{/* Permission Status */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, styles.sectionTitleDark]}>
Permission Status
</Text>
<View style={[
styles.permissionCard,
permissionGranted ? styles.permissionGrantedDark : styles.permissionDeniedDark
]}>
<Text style={[
styles.permissionText,
permissionGranted ? styles.permissionTextGrantedDark : styles.permissionTextDeniedDark
]}>
{permissionGranted ? '✓ Microphone permission granted' : '✗ Microphone permission denied'}
</Text>
</View>
</View>
{/* Recording Section */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, styles.sectionTitleDark]}>
Audio Recording
</Text>
{/* Recording Status */}
<View style={[styles.statusCard, styles.statusCardDark]}>
<Text style={[styles.statusLabel, styles.statusLabelDark]}>
Status
</Text>
<Text style={[styles.statusValue, styles.statusValueDark]}>
{recordingStatus === 'idle' && 'Ready to record'}
{recordingStatus === 'recording' && '🔴 Recording...'}
{recordingStatus === 'processing' && 'Processing...'}
</Text>
</View>
{/* Recording Controls */}
<View style={styles.controlsRow}>
<Pressable
onPress={handleStartRecording}
disabled={recordingStatus !== 'idle' || !permissionGranted}
style={[
styles.button,
(recordingStatus !== 'idle' || !permissionGranted)
? styles.buttonDisabledDark
: styles.buttonBlueDark
]}
>
<Text style={[
styles.buttonText,
(recordingStatus !== 'idle' || !permissionGranted) && styles.buttonTextDisabledDark
]}>
Start Recording
</Text>
</Pressable>
<Pressable
onPress={handleStopRecording}
disabled={recordingStatus !== 'recording'}
style={[
styles.button,
recordingStatus !== 'recording'
? styles.buttonDisabledDark
: styles.buttonRedDark
]}
>
<Text style={[
styles.buttonText,
recordingStatus !== 'recording' && styles.buttonTextDisabledDark
]}>
Stop Recording
</Text>
</Pressable>
</View>
{/* Last Recording Info */}
{lastRecordingSize !== null && (
<View style={[styles.infoCard, styles.infoCardDark]}>
<Text style={[styles.infoCardLabel, styles.infoCardLabelDark]}>
Last Recording
</Text>
<Text style={[styles.infoCardValue, styles.infoCardValueDark]}>
Size: {lastRecordingSize} bytes
</Text>
<Text style={[styles.infoCardSubtext, styles.infoCardSubtextDark]}>
Type: audio/m4a
</Text>
</View>
)}
</View>
{/* Playback Section */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, styles.sectionTitleDark]}>
Audio Playback
</Text>
{/* Playback Status */}
<View style={[styles.statusCard, styles.statusCardDark]}>
<Text style={[styles.statusLabel, styles.statusLabelDark]}>
Status
</Text>
<Text style={[styles.statusValue, styles.statusValueDark]}>
{playbackStatus}
</Text>
</View>
{/* Playback Controls */}
<View style={styles.gap3}>
<Pressable
onPress={handlePlayLastRecording}
disabled={!lastRecordedBlob}
style={[
styles.fullWidthButton,
!lastRecordedBlob
? styles.buttonDisabledDark
: styles.buttonGreenDark
]}
>
<Text style={[
styles.buttonText,
!lastRecordedBlob && styles.buttonTextDisabledDark
]}>
Play Last Recording
</Text>
</Pressable>
<Pressable
onPress={handlePlayTestAudio}
style={[styles.fullWidthButton, styles.buttonPurpleDark]}
>
<Text style={styles.buttonText}>
Play Test Audio
</Text>
</Pressable>
<Pressable
onPress={handleStopPlayback}
style={[styles.fullWidthButton, styles.buttonOrangeDark]}
>
<Text style={styles.buttonText}>
Stop Playback
</Text>
</Pressable>
</View>
</View>
{/* Audio Settings Info */}
<View style={[styles.configCard, styles.configCardDark]}>
<Text style={[styles.configTitle, styles.configTitleDark]}>
Audio Configuration
</Text>
<Text style={[styles.configText, styles.configTextDark]}>
• Sample Rate: 16000 Hz
</Text>
<Text style={[styles.configText, styles.configTextDark]}>
• Channels: 1 (Mono)
</Text>
<Text style={[styles.configText, styles.configTextDark]}>
• Format: M4A/AAC
</Text>
<Text style={[styles.configText, styles.configTextDark]}>
• Optimized for voice/speech
</Text>
</View>
</View>
</ScrollView>
);
}
File diff suppressed because it is too large Load Diff
+596
View File
@@ -0,0 +1,596 @@
import { useState, useEffect } from "react";
import {
View,
Text,
ScrollView,
TextInput,
Switch,
Pressable,
Alert,
ActivityIndicator,
} from "react-native";
import { router } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { useSettings } from "@/hooks/use-settings";
import { theme as defaultTheme } from "@/styles/theme";
const styles = StyleSheet.create((theme) => ({
loadingContainer: {
flex: 1,
backgroundColor: theme.colors.background,
alignItems: "center",
justifyContent: "center",
},
loadingText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
},
container: {
flex: 1,
backgroundColor: theme.colors.background,
},
header: {
paddingHorizontal: theme.spacing[6],
paddingBottom: theme.spacing[4],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
headerRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
headerTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize["3xl"],
fontWeight: theme.fontWeight.bold,
},
cancelButton: {
color: theme.colors.palette.blue[500],
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
scrollView: {
flex: 1,
},
content: {
padding: theme.spacing[6],
},
section: {
marginBottom: theme.spacing[8],
},
sectionTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[4],
},
label: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
marginBottom: theme.spacing[2],
},
input: {
backgroundColor: theme.colors.card,
color: theme.colors.foreground,
padding: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
},
helperText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
marginBottom: theme.spacing[3],
},
testButton: {
padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[3],
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.palette.blue[600],
},
testButtonDisabled: {
backgroundColor: theme.colors.muted,
},
testButtonText: {
color: theme.colors.palette.white,
fontWeight: theme.fontWeight.semibold,
marginLeft: theme.spacing[2],
},
testResultSuccess: {
padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.palette.green[900],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.palette.green[600],
},
testResultError: {
padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.palette.red[900],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.palette.red[600],
},
testResultTextSuccess: {
color: theme.colors.palette.green[200],
},
testResultTextError: {
color: theme.colors.palette.red[200],
},
settingCard: {
backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[4],
marginBottom: theme.spacing[3],
},
settingRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
settingContent: {
flex: 1,
},
settingTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
marginBottom: theme.spacing[1],
},
settingDescription: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
themeCardDisabled: {
backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[4],
opacity: theme.opacity[50],
},
themeHelpText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
marginBottom: theme.spacing[3],
},
themeOption: {
flexDirection: "row",
alignItems: "center",
paddingVertical: theme.spacing[2],
},
radioOuter: {
width: 20,
height: 20,
borderRadius: theme.borderRadius.full,
borderWidth: theme.borderWidth[2],
marginRight: theme.spacing[3],
alignItems: "center",
justifyContent: "center",
},
radioOuterSelected: {
borderColor: theme.colors.palette.blue[500],
},
radioOuterUnselected: {
borderColor: theme.colors.border,
},
radioInner: {
width: 12,
height: 12,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.blue[500],
},
themeOptionText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.base,
textTransform: "capitalize",
},
saveButton: {
padding: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[3],
backgroundColor: theme.colors.palette.blue[500],
},
saveButtonDisabled: {
backgroundColor: theme.colors.palette.blue[900],
opacity: theme.opacity[50],
},
saveButtonText: {
color: theme.colors.palette.white,
textAlign: "center",
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
resetButton: {
padding: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.destructive,
},
resetButtonText: {
color: theme.colors.destructive,
textAlign: "center",
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
footer: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
paddingTop: theme.spacing[6],
},
footerText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
footerVersion: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
textAlign: "center",
marginTop: theme.spacing[1],
},
}));
export default function SettingsScreen() {
const { settings, isLoading, updateSettings, resetSettings } = useSettings();
const insets = useSafeAreaInsets();
const [serverUrl, setServerUrl] = useState(settings.serverUrl);
const [useSpeaker, setUseSpeaker] = useState(settings.useSpeaker);
const [keepScreenOn, setKeepScreenOn] = useState(settings.keepScreenOn);
const [theme, setTheme] = useState<"dark" | "light" | "auto">(settings.theme);
const [hasChanges, setHasChanges] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<{
success: boolean;
message: string;
} | null>(null);
// Update local state when settings load
useEffect(() => {
setServerUrl(settings.serverUrl);
setUseSpeaker(settings.useSpeaker);
setKeepScreenOn(settings.keepScreenOn);
setTheme(settings.theme);
}, [settings]);
// Track changes
useEffect(() => {
const changed =
serverUrl !== settings.serverUrl ||
useSpeaker !== settings.useSpeaker ||
keepScreenOn !== settings.keepScreenOn ||
theme !== settings.theme;
setHasChanges(changed);
}, [serverUrl, useSpeaker, keepScreenOn, theme, settings]);
function validateServerUrl(url: string): boolean {
try {
const urlObj = new URL(url);
return urlObj.protocol === "ws:" || urlObj.protocol === "wss:";
} catch {
return false;
}
}
async function handleSave() {
// Validate server URL
if (!validateServerUrl(serverUrl)) {
Alert.alert(
"Invalid URL",
"Server URL must be a valid WebSocket URL (ws:// or wss://)",
[{ text: "OK" }]
);
return;
}
try {
await updateSettings({
serverUrl,
useSpeaker,
keepScreenOn,
theme,
});
Alert.alert(
"Settings Saved",
"Your settings have been saved successfully.",
[
{
text: "OK",
onPress: () => router.back(),
},
]
);
} catch (error) {
Alert.alert("Error", "Failed to save settings. Please try again.", [
{ text: "OK" },
]);
}
}
async function handleReset() {
Alert.alert(
"Reset Settings",
"Are you sure you want to reset all settings to defaults?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Reset",
style: "destructive",
onPress: async () => {
try {
await resetSettings();
Alert.alert(
"Settings Reset",
"All settings have been reset to defaults."
);
} catch (error) {
Alert.alert(
"Error",
"Failed to reset settings. Please try again."
);
}
},
},
]
);
}
function handleCancel() {
if (hasChanges) {
Alert.alert(
"Discard Changes",
"You have unsaved changes. Are you sure you want to go back?",
[
{ text: "Stay", style: "cancel" },
{
text: "Discard",
style: "destructive",
onPress: () => router.back(),
},
]
);
} else {
router.back();
}
}
async function handleTestConnection() {
if (!validateServerUrl(serverUrl)) {
Alert.alert(
"Invalid URL",
"Server URL must be a valid WebSocket URL (ws:// or wss://)",
[{ text: "OK" }]
);
return;
}
setIsTesting(true);
setTestResult(null);
try {
const ws = new WebSocket(serverUrl);
const timeout = setTimeout(() => {
ws.close();
setTestResult({
success: false,
message: "Connection timeout - server did not respond",
});
setIsTesting(false);
}, 5000);
ws.onopen = () => {
clearTimeout(timeout);
ws.close();
setTestResult({
success: true,
message: "Connection successful",
});
setIsTesting(false);
};
ws.onerror = () => {
clearTimeout(timeout);
setTestResult({
success: false,
message: "Connection failed - check URL and network",
});
setIsTesting(false);
};
} catch (error) {
setTestResult({
success: false,
message: "Failed to create connection",
});
setIsTesting(false);
}
}
if (isLoading) {
return (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>Loading settings...</Text>
</View>
);
}
return (
<View style={styles.container}>
{/* Header */}
<View style={[styles.header, { paddingTop: insets.top + 16 }]}>
<View style={styles.headerRow}>
<Text style={styles.headerTitle}>Settings</Text>
<Pressable onPress={handleCancel}>
<Text style={styles.cancelButton}>Cancel</Text>
</Pressable>
</View>
</View>
<ScrollView style={styles.scrollView}>
<View style={styles.content}>
{/* Server Configuration */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Server Configuration</Text>
<Text style={styles.label}>WebSocket URL</Text>
<TextInput
style={styles.input}
placeholder="wss://example.com/ws"
placeholderTextColor={defaultTheme.colors.mutedForeground}
value={serverUrl}
onChangeText={(text) => {
setServerUrl(text);
setTestResult(null);
}}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
<Text style={styles.helperText}>
Must be a valid WebSocket URL (ws:// or wss://)
</Text>
{/* Test Connection Button */}
<Pressable
onPress={handleTestConnection}
disabled={isTesting || !validateServerUrl(serverUrl)}
style={[
styles.testButton,
(isTesting || !validateServerUrl(serverUrl)) &&
styles.testButtonDisabled,
]}
>
{isTesting ? (
<>
<ActivityIndicator size="small" color="#fff" />
<Text style={styles.testButtonText}>Testing...</Text>
</>
) : (
<Text style={styles.testButtonText}>Test Connection</Text>
)}
</Pressable>
{/* Test Result */}
{testResult && (
<View
style={
testResult.success
? styles.testResultSuccess
: styles.testResultError
}
>
<Text
style={
testResult.success
? styles.testResultTextSuccess
: styles.testResultTextError
}
>
{testResult.message}
</Text>
</View>
)}
</View>
{/* Audio Settings */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Audio</Text>
<View style={styles.settingCard}>
<View style={styles.settingRow}>
<View style={styles.settingContent}>
<Text style={styles.settingTitle}>Use Speaker</Text>
<Text style={styles.settingDescription}>
Play audio through speaker instead of earpiece
</Text>
</View>
<Switch
value={useSpeaker}
onValueChange={setUseSpeaker}
trackColor={{ false: defaultTheme.colors.palette.gray[700], true: defaultTheme.colors.palette.blue[500] }}
thumbColor={useSpeaker ? defaultTheme.colors.palette.blue[400] : defaultTheme.colors.palette.gray[300]}
/>
</View>
</View>
<View style={styles.settingCard}>
<View style={styles.settingRow}>
<View style={styles.settingContent}>
<Text style={styles.settingTitle}>Keep Screen On</Text>
<Text style={styles.settingDescription}>
Prevent screen from sleeping during voice sessions
</Text>
</View>
<Switch
value={keepScreenOn}
onValueChange={setKeepScreenOn}
trackColor={{ false: defaultTheme.colors.palette.gray[700], true: defaultTheme.colors.palette.blue[500] }}
thumbColor={keepScreenOn ? defaultTheme.colors.palette.blue[400] : defaultTheme.colors.palette.gray[300]}
/>
</View>
</View>
</View>
{/* Theme Settings */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Theme</Text>
<View style={styles.themeCardDisabled}>
<Text style={styles.themeHelpText}>
Theme selection (coming soon)
</Text>
{(["dark", "light", "auto"] as const).map((themeOption) => (
<Pressable
key={themeOption}
disabled
style={styles.themeOption}
>
<View
style={[
styles.radioOuter,
theme === themeOption
? styles.radioOuterSelected
: styles.radioOuterUnselected,
]}
>
{theme === themeOption && (
<View style={styles.radioInner} />
)}
</View>
<Text style={styles.themeOptionText}>{themeOption}</Text>
</Pressable>
))}
</View>
</View>
{/* Action Buttons */}
<View style={styles.section}>
<Pressable
style={[
styles.saveButton,
!hasChanges && styles.saveButtonDisabled,
]}
onPress={handleSave}
disabled={!hasChanges}
>
<Text style={styles.saveButtonText}>Save Settings</Text>
</Pressable>
<Pressable style={styles.resetButton} onPress={handleReset}>
<Text style={styles.resetButtonText}>Reset to Defaults</Text>
</Pressable>
</View>
{/* App Info */}
<View style={styles.footer}>
<Text style={styles.footerText}>Voice Assistant Mobile</Text>
<Text style={styles.footerVersion}>Version 1.0.0</Text>
</View>
</View>
</ScrollView>
</View>
);
}
@@ -0,0 +1,220 @@
import { View, Text, Pressable, ScrollView } from 'react-native';
import { StyleSheet } from 'react-native-unistyles';
import type { AgentStatus } from '@server/server/acp/types';
export interface ActiveProcessesProps {
agents: Array<{
id: string;
status: AgentStatus;
type: 'claude';
currentModeId?: string;
availableModes?: Array<{ id: string; name: string; description?: string | null }>;
}>;
commands: Array<{
id: string;
name: string;
workingDirectory: string;
currentCommand: string;
isDead: boolean;
exitCode: number | null;
}>;
activeProcessId: string | null;
activeProcessType: 'agent' | null;
onSelectAgent: (id: string) => void;
onBackToOrchestrator: () => void;
}
function getAgentStatusColor(status: AgentStatus): string {
switch (status) {
case 'initializing':
return '#f59e0b'; // orange
case 'ready':
return '#3b82f6'; // blue
case 'processing':
return '#fbbf24'; // yellow
case 'completed':
return '#22c55e'; // green
case 'failed':
return '#ef4444'; // red
case 'killed':
return '#9ca3af'; // gray
default:
return '#9ca3af';
}
}
function getModeName(modeId?: string, availableModes?: Array<{ id: string; name: string }>): string {
if (!modeId) return 'unknown';
const mode = availableModes?.find((m) => m.id === modeId);
return mode?.name || modeId;
}
function getModeColor(modeId?: string): string {
if (!modeId) return '#9ca3af'; // gray
// Color based on common mode types
if (modeId.includes('ask')) return '#f59e0b'; // orange - asks permission
if (modeId.includes('code')) return '#22c55e'; // green - writes code
if (modeId.includes('architect') || modeId.includes('plan')) return '#3b82f6'; // blue - plans
return '#9ca3af'; // gray - unknown
}
const styles = StyleSheet.create((theme) => ({
container: {
backgroundColor: theme.colors.card,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
header: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
backButton: {
backgroundColor: theme.colors.muted,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
backButtonActive: {
backgroundColor: theme.colors.palette.zinc[700],
},
backButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: '600',
textAlign: 'center',
},
scrollView: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
processItem: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
processItemActive: {
backgroundColor: theme.colors.primary,
},
processItemInactive: {
backgroundColor: theme.colors.muted,
},
agentIcon: {
width: 12,
height: 12,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.blue[500],
},
commandIcon: {
width: 12,
height: 12,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.palette.purple[500],
},
processText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: '500',
},
statusDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
},
modeIndicator: {
width: 6,
height: 6,
borderRadius: theme.borderRadius.full,
opacity: 0.3,
},
}));
export function ActiveProcesses({
agents,
commands,
activeProcessId,
activeProcessType,
onSelectAgent,
onBackToOrchestrator,
}: ActiveProcessesProps) {
const hasActiveProcess = activeProcessId !== null && activeProcessType !== null;
if (agents.length === 0 && commands.length === 0) {
return null;
}
return (
<View style={styles.container}>
{hasActiveProcess && (
<View style={styles.header}>
<Pressable
onPress={onBackToOrchestrator}
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonActive]}
>
<Text style={styles.backButtonText}>Back to Chat</Text>
</Pressable>
</View>
)}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={{ gap: 8 }}
>
{agents.map((agent) => {
const modeName = getModeName(agent.currentModeId, agent.availableModes);
const isActive = activeProcessType === 'agent' && activeProcessId === agent.id;
return (
<Pressable
key={`agent-${agent.id}`}
onPress={() => onSelectAgent(agent.id)}
style={({ pressed }) => [
styles.processItem,
isActive ? styles.processItemActive : styles.processItemInactive,
pressed && { opacity: 0.7 },
]}
>
<View style={styles.agentIcon} />
<Text style={styles.processText}>{agent.id.substring(0, 8)}</Text>
<View style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]} />
{agent.currentModeId && (
<View style={[styles.modeIndicator, { backgroundColor: getModeColor(agent.currentModeId) }]} />
)}
</Pressable>
);
})}
{commands.map((command) => {
const statusColor = command.isDead
? command.exitCode === 0
? '#22c55e'
: '#ef4444'
: '#3b82f6';
return (
<View key={`command-${command.id}`} style={[styles.processItem, styles.processItemInactive]}>
<View style={styles.commandIcon} />
<Text style={styles.processText} numberOfLines={1}>
{command.name || command.currentCommand.substring(0, 20)}
</Text>
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
</View>
);
})}
</ScrollView>
</View>
);
}
@@ -0,0 +1,453 @@
import { useState, useEffect, useRef } from 'react';
import { View, Text, ScrollView, Pressable } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { StyleSheet } from 'react-native-unistyles';
import type { AgentStatus } from '@server/server/acp/types';
import type { SessionNotification } from '@agentclientprotocol/sdk';
export interface AgentStreamViewProps {
agentId: string;
agent: {
id: string;
status: AgentStatus;
createdAt: Date;
type: 'claude';
};
updates: Array<{
timestamp: Date;
notification: SessionNotification;
}>;
onBack: () => void;
onKillAgent: (agentId: string) => void;
onCancelAgent: (agentId: string) => void;
}
function getStatusColor(status: AgentStatus): string {
switch (status) {
case 'initializing':
return '#f59e0b';
case 'ready':
return '#3b82f6';
case 'processing':
return '#fbbf24';
case 'completed':
return '#22c55e';
case 'failed':
return '#ef4444';
case 'killed':
return '#9ca3af';
default:
return '#9ca3af';
}
}
function getStatusIcon(status: AgentStatus): string {
switch (status) {
case 'initializing':
case 'processing':
return '⏳';
case 'ready':
case 'completed':
return '✓';
case 'failed':
case 'killed':
return '✗';
default:
return '•';
}
}
function formatTimestamp(date: Date): string {
return new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hour12: true,
}).format(date);
}
function AgentUpdate({
update,
}: {
update: { timestamp: Date; notification: SessionNotification };
}) {
const [isExpanded, setIsExpanded] = useState(true);
const notification = update.notification as any;
// Parse different notification types
if (notification.type === 'sessionUpdate') {
const sessionUpdate = notification.sessionUpdate || {};
// Handle message chunks
if (sessionUpdate.kind === 'agent_message_chunk') {
return (
<View style={stylesheet.updateCard}>
<Text style={stylesheet.timestampText}>
{formatTimestamp(update.timestamp)}
</Text>
<Text style={stylesheet.messageText}>{sessionUpdate.chunk || ''}</Text>
</View>
);
}
// Handle tool calls
if (sessionUpdate.kind === 'tool_call') {
return (
<View style={stylesheet.collapsibleCard}>
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
style={stylesheet.collapsibleHeader}
>
<Text style={stylesheet.timestampText}>
{formatTimestamp(update.timestamp)}
</Text>
<Text style={stylesheet.toolCallText}>
Tool Call: {sessionUpdate.toolName || 'unknown'}
</Text>
</Pressable>
{isExpanded && sessionUpdate.arguments && (
<View style={stylesheet.collapsibleContent}>
<Text style={stylesheet.codeText}>
{JSON.stringify(sessionUpdate.arguments, null, 2)}
</Text>
</View>
)}
</View>
);
}
// Handle tool call updates
if (sessionUpdate.kind === 'tool_call_update') {
return (
<View style={stylesheet.updateCard}>
<Text style={stylesheet.timestampText}>
{formatTimestamp(update.timestamp)}
</Text>
<Text style={stylesheet.updateStatusText}>
{sessionUpdate.status || 'updating'}
</Text>
</View>
);
}
// Handle available commands
if (sessionUpdate.kind === 'available_commands_update') {
const commands = sessionUpdate.commands || [];
return (
<View style={stylesheet.collapsibleCard}>
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
style={stylesheet.collapsibleHeader}
>
<Text style={stylesheet.timestampText}>
{formatTimestamp(update.timestamp)}
</Text>
<Text style={stylesheet.commandsText}>
Available Commands ({commands.length})
</Text>
</Pressable>
{isExpanded && (
<View style={stylesheet.collapsibleContent}>
{commands.map((cmd: any, idx: number) => (
<Text key={idx} style={stylesheet.commandItem}>
• {cmd.name || cmd}
</Text>
))}
</View>
)}
</View>
);
}
// Generic session update
return (
<View style={stylesheet.updateCard}>
<Text style={stylesheet.timestampText}>
{formatTimestamp(update.timestamp)}
</Text>
<Text style={stylesheet.codeText}>
{JSON.stringify(sessionUpdate, null, 2)}
</Text>
</View>
);
}
// Fallback for unknown notification types
return (
<View style={stylesheet.updateCard}>
<Text style={stylesheet.timestampText}>
{formatTimestamp(update.timestamp)}
</Text>
<Text style={stylesheet.codeText}>
{JSON.stringify(notification, null, 2)}
</Text>
</View>
);
}
export function AgentStreamView({
agentId,
agent,
updates,
onBack,
onKillAgent,
onCancelAgent,
}: AgentStreamViewProps) {
const scrollViewRef = useRef<ScrollView>(null);
const insets = useSafeAreaInsets();
// Auto-scroll to bottom when new updates arrive
useEffect(() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}, [updates]);
const canCancel = agent.status === 'processing';
const canKill = agent.status !== 'killed' && agent.status !== 'completed';
return (
<View style={stylesheet.container}>
{/* Header */}
<View style={[stylesheet.header, { paddingTop: insets.top + 16 }]}>
{/* Back button */}
<Pressable
onPress={onBack}
style={stylesheet.backButton}
>
<Text style={stylesheet.backButtonText}>
← Back to Chat
</Text>
</Pressable>
{/* Agent info */}
<View style={stylesheet.agentInfoRow}>
<View style={stylesheet.agentIdRow}>
<Text style={stylesheet.agentLabel}>Agent:</Text>
<Text style={stylesheet.agentId}>
{agentId.substring(0, 8)}
</Text>
</View>
<View
style={[
stylesheet.statusBadge,
{ backgroundColor: getStatusColor(agent.status) }
]}
>
<Text style={stylesheet.statusIcon}>{getStatusIcon(agent.status)}</Text>
<Text style={stylesheet.statusText}>{agent.status}</Text>
</View>
</View>
<Text style={stylesheet.createdText}>
Created: {formatTimestamp(agent.createdAt)}
</Text>
{/* Control buttons */}
{(canCancel || canKill) && (
<View style={stylesheet.controlButtons}>
{canCancel && (
<Pressable
onPress={() => onCancelAgent(agentId)}
style={stylesheet.cancelButton}
>
<Text style={stylesheet.controlButtonText}>
Cancel
</Text>
</Pressable>
)}
{canKill && (
<Pressable
onPress={() => onKillAgent(agentId)}
style={stylesheet.killButton}
>
<Text style={stylesheet.controlButtonText}>
Kill
</Text>
</Pressable>
)}
</View>
)}
</View>
{/* Updates list */}
<ScrollView
ref={scrollViewRef}
style={stylesheet.scrollView}
contentContainerStyle={{ paddingTop: 16, paddingBottom: Math.max(insets.bottom, 32) }}
>
{updates.length === 0 ? (
<View style={stylesheet.emptyState}>
<Text style={stylesheet.emptyStateText}>
No updates yet.{'\n'}Waiting for agent activity...
</Text>
</View>
) : (
updates.map((update, idx) => <AgentUpdate key={idx} update={update} />)
)}
</ScrollView>
</View>
);
}
const stylesheet = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.background,
},
header: {
backgroundColor: theme.colors.card,
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
paddingHorizontal: theme.spacing[4],
paddingBottom: theme.spacing[4],
},
backButton: {
backgroundColor: theme.colors.muted,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[4],
},
backButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
textAlign: "center",
},
agentInfoRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginBottom: theme.spacing[3],
},
agentIdRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
agentLabel: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
agentId: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontFamily: "monospace",
},
statusBadge: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.full,
},
statusIcon: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
},
statusText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
createdText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
},
controlButtons: {
flexDirection: "row",
gap: theme.spacing[2],
marginTop: theme.spacing[4],
},
cancelButton: {
flex: 1,
backgroundColor: theme.colors.palette.orange[600],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
killButton: {
flex: 1,
backgroundColor: theme.colors.destructive,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
controlButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
textAlign: "center",
},
scrollView: {
flex: 1,
paddingHorizontal: theme.spacing[4],
},
emptyState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[12],
},
emptyStateText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
updateCard: {
backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[3],
marginBottom: theme.spacing[2],
},
collapsibleCard: {
backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: "hidden",
},
collapsibleHeader: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
collapsibleContent: {
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[3],
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
},
timestampText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
marginBottom: theme.spacing[1],
},
messageText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
toolCallText: {
color: theme.colors.palette.blue[400],
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
updateStatusText: {
color: theme.colors.palette.yellow[400],
fontSize: theme.fontSize.sm,
},
commandsText: {
color: theme.colors.palette.green[400],
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
commandItem: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
codeText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
fontFamily: "monospace",
marginTop: theme.spacing[2],
},
}));
@@ -0,0 +1,266 @@
import {
View,
Text,
ScrollView,
Pressable,
Modal,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useEffect } from "react";
import { StyleSheet } from "react-native-unistyles";
export interface Artifact {
id: string;
type: "markdown" | "diff" | "image" | "code";
title: string;
content: string;
isBase64: boolean;
}
interface ArtifactDrawerProps {
artifact: Artifact | null;
onClose: () => void;
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.background,
flexDirection: "column",
},
header: {
paddingBottom: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
headerRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
titleContainer: {
flex: 1,
marginRight: theme.spacing[4],
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.bold,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
badge: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.full,
},
badgeMarkdown: {
backgroundColor: theme.colors.primary,
},
badgeDiff: {
backgroundColor: theme.colors.palette.purple[600],
},
badgeImage: {
backgroundColor: theme.colors.palette.green[600],
},
badgeCode: {
backgroundColor: theme.colors.palette.orange[600],
},
badgeText: {
color: theme.colors.primaryForeground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
closeButton: {
backgroundColor: theme.colors.muted,
width: 40,
height: 40,
borderRadius: 20,
alignItems: "center",
justifyContent: "center",
},
closeButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.bold,
},
contentScroll: {
flex: 1,
backgroundColor: theme.colors.background,
},
contentScrollContainer: {
padding: theme.spacing[4],
flexGrow: 1,
},
imagePlaceholder: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
imagePlaceholderText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
imagePlaceholderSubtext: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[2],
},
codeContainer: {
backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[4],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
codeText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontFamily: "monospace",
},
metadataContainer: {
backgroundColor: theme.colors.card,
padding: theme.spacing[3],
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
marginTop: theme.spacing[2],
},
metadataTitle: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[2],
},
metadataRow: {
flexDirection: "row",
marginBottom: theme.spacing[1],
},
metadataLabel: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
width: 80,
},
metadataValue: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
flex: 1,
fontFamily: "monospace",
},
}));
export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
useEffect(() => {
if (!artifact) return;
console.log(
"[ArtifactDrawer] Showing artifact:",
artifact.id,
artifact.type,
artifact.title
);
}, [artifact]);
if (!artifact) {
return null;
}
// Decode content if base64
const content = artifact.isBase64 ? atob(artifact.content) : artifact.content;
// Type badge style mapping
const typeBadgeStyles = {
markdown: styles.badgeMarkdown,
diff: styles.badgeDiff,
image: styles.badgeImage,
code: styles.badgeCode,
};
return (
<Modal
visible={!!artifact}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={onClose}
>
<SafeAreaView edges={["top", "bottom"]} style={styles.container}>
{/* Header */}
<View style={styles.header}>
<View style={styles.headerRow}>
<View style={styles.titleContainer}>
<Text style={styles.title} numberOfLines={2}>
{artifact.title}
</Text>
</View>
<View style={styles.headerActions}>
<View
style={[
styles.badge,
typeBadgeStyles[artifact.type],
]}
>
<Text style={styles.badgeText}>
{artifact.type.toUpperCase()}
</Text>
</View>
<Pressable onPress={onClose} style={styles.closeButton}>
<Text style={styles.closeButtonText}>×</Text>
</Pressable>
</View>
</View>
</View>
{/* Content */}
<ScrollView
style={styles.contentScroll}
contentContainerStyle={styles.contentScrollContainer}
>
{artifact.type === "image" ? (
<View style={styles.imagePlaceholder}>
<Text style={styles.imagePlaceholderText}>
Image viewing not yet implemented
</Text>
<Text style={styles.imagePlaceholderSubtext}>
Base64 image data received
</Text>
</View>
) : (
<View style={styles.codeContainer}>
<ScrollView horizontal showsHorizontalScrollIndicator={true}>
<Text style={styles.codeText}>{content}</Text>
</ScrollView>
</View>
)}
</ScrollView>
{/* Metadata - Fixed at bottom */}
<View style={styles.metadataContainer}>
<Text style={styles.metadataTitle}>METADATA</Text>
<View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>ID:</Text>
<Text style={styles.metadataValue}>{artifact.id}</Text>
</View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Type:</Text>
<Text style={styles.metadataValue}>{artifact.type}</Text>
</View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Encoding:</Text>
<Text style={styles.metadataValue}>
{artifact.isBase64 ? "Base64" : "Plain text"}
</Text>
</View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Size:</Text>
<Text style={styles.metadataValue}>
{content.length.toLocaleString()} characters
</Text>
</View>
</View>
</View>
</SafeAreaView>
</Modal>
);
}
@@ -0,0 +1,53 @@
import { View, Text } from 'react-native';
import { StyleSheet } from 'react-native-unistyles';
interface ConnectionStatusProps {
isConnected: boolean;
}
const styles = StyleSheet.create((theme) => ({
container: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
row: {
flexDirection: 'row',
alignItems: 'center',
},
dot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
marginRight: theme.spacing[2],
},
dotConnected: {
backgroundColor: theme.colors.palette.green[500],
},
dotDisconnected: {
backgroundColor: theme.colors.destructive,
},
text: {
fontSize: theme.fontSize.sm,
},
textConnected: {
color: theme.colors.palette.green[500],
},
textDisconnected: {
color: theme.colors.destructive,
},
}));
export function ConnectionStatus({ isConnected }: ConnectionStatusProps) {
return (
<View style={styles.container}>
<View style={styles.row}>
<View style={[styles.dot, isConnected ? styles.dotConnected : styles.dotDisconnected]} />
<Text style={[styles.text, isConnected ? styles.textConnected : styles.textDisconnected]}>
{isConnected ? 'Connected' : 'Disconnected'}
</Text>
</View>
</View>
);
}
@@ -0,0 +1,408 @@
import { useState, useEffect } from 'react';
import { Modal, View, Text, Pressable, ScrollView, Alert, KeyboardAvoidingView, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { MessageSquare, X, Plus, Trash2 } from 'lucide-react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { StyleSheet } from 'react-native-unistyles';
import type { UseWebSocketReturn } from '../hooks/use-websocket';
const STORAGE_KEY = '@voice-dev:conversation-id';
interface Conversation {
id: string;
lastUpdated: string;
messageCount: number;
}
interface ConversationSelectorProps {
currentConversationId: string | null;
onSelectConversation: (conversationId: string | null) => void;
websocket: UseWebSocketReturn;
}
export function ConversationSelector({
currentConversationId,
onSelectConversation,
websocket,
}: ConversationSelectorProps) {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// Listen for conversation list responses
useEffect(() => {
const unsubscribe = websocket.on('list_conversations_response', (message) => {
if (message.type !== 'list_conversations_response') return;
setConversations(message.payload.conversations);
setIsLoading(false);
});
return unsubscribe;
}, [websocket]);
// Listen for delete conversation responses
useEffect(() => {
const unsubscribe = websocket.on('delete_conversation_response', (message) => {
if (message.type !== 'delete_conversation_response') return;
console.log('[ConversationSelector] Delete response:', message.payload);
if (message.payload.success) {
// Refresh conversations list
fetchConversations();
// If we deleted the current conversation, start a new one
if (message.payload.conversationId === currentConversationId) {
handleNewConversation();
}
} else {
Alert.alert('Error', `Failed to delete conversation: ${message.payload.error}`);
}
});
return unsubscribe;
}, [websocket, currentConversationId]);
// Fetch conversations when modal opens
useEffect(() => {
if (isOpen) {
fetchConversations();
}
}, [isOpen]);
function fetchConversations() {
setIsLoading(true);
console.log('[ConversationSelector] Requesting conversations via WebSocket');
websocket.send({
type: 'session',
message: {
type: 'list_conversations_request',
},
});
}
function handleDeleteConversation(id: string) {
Alert.alert(
'Delete Conversation',
'Are you sure you want to delete this conversation?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: () => {
console.log('[ConversationSelector] Deleting conversation via WebSocket:', id);
websocket.send({
type: 'session',
message: {
type: 'delete_conversation_request',
conversationId: id,
},
});
},
},
]
);
}
function handleClearAll() {
Alert.alert(
'Clear All Conversations',
'Are you sure you want to delete all conversations?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Clear All',
style: 'destructive',
onPress: () => {
console.log('[ConversationSelector] Clearing all conversations via WebSocket');
// Delete all conversations
conversations.forEach((conv) => {
websocket.send({
type: 'session',
message: {
type: 'delete_conversation_request',
conversationId: conv.id,
},
});
});
// Clear local state
setConversations([]);
// Start new conversation
handleNewConversation();
setIsOpen(false);
},
},
]
);
}
async function handleSelectConversation(id: string) {
try {
// Save to AsyncStorage for persistence
await AsyncStorage.setItem(STORAGE_KEY, id);
onSelectConversation(id);
setIsOpen(false);
} catch (error) {
console.error('[ConversationSelector] Failed to save conversation ID:', error);
}
}
async function handleNewConversation() {
try {
// Clear saved conversation ID
await AsyncStorage.removeItem(STORAGE_KEY);
onSelectConversation(null);
setIsOpen(false);
} catch (error) {
console.error('[ConversationSelector] Failed to clear conversation ID:', error);
}
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
return (
<View>
<Pressable
onPress={() => setIsOpen(true)}
style={styles.triggerButton}
>
<MessageSquare size={20} color="white" />
</Pressable>
<Modal
visible={isOpen}
animationType="slide"
transparent={true}
onRequestClose={() => setIsOpen(false)}
>
<View style={styles.modalOverlay}>
<Pressable
style={styles.modalBackdrop}
onPress={() => setIsOpen(false)}
/>
<View style={styles.modalContent}>
<View style={styles.modalInner}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>
Conversations {conversations.length > 0 && `(${conversations.length})`}
</Text>
<Pressable onPress={() => setIsOpen(false)}>
<X size={24} color="white" />
</Pressable>
</View>
{/* New Conversation Button */}
<View style={styles.newButtonContainer}>
<Pressable
onPress={handleNewConversation}
style={styles.newButton}
>
<Plus size={20} color="white" />
<Text style={styles.newButtonText}>New Conversation</Text>
</Pressable>
</View>
{/* Conversations List */}
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
>
{isLoading && (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>Loading...</Text>
</View>
)}
{!isLoading && conversations.length === 0 && (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No saved conversations</Text>
</View>
)}
{!isLoading && conversations.map((conversation) => (
<View
key={conversation.id}
style={[
styles.conversationItem,
conversation.id === currentConversationId && styles.conversationItemActive
]}
>
<Pressable
style={styles.conversationContent}
onPress={() => handleSelectConversation(conversation.id)}
>
<Text style={styles.conversationTitle}>
{conversation.messageCount} messages
</Text>
<Text style={styles.conversationDate}>
{formatDate(conversation.lastUpdated)}
</Text>
</Pressable>
<Pressable
onPress={() => handleDeleteConversation(conversation.id)}
style={styles.deleteButton}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Trash2 size={20} color="#ef4444" />
</Pressable>
</View>
))}
{!isLoading && conversations.length > 0 && (
<View style={styles.clearAllContainer}>
<Pressable
onPress={handleClearAll}
style={styles.clearAllButton}
>
<Trash2 size={18} color="#ef4444" />
<Text style={styles.clearAllText}>Clear All</Text>
</Pressable>
</View>
)}
</ScrollView>
</View>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
triggerButton: {
backgroundColor: theme.colors.muted,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
},
modalBackdrop: {
flex: 1,
},
modalContent: {
backgroundColor: theme.colors.card,
borderTopLeftRadius: theme.spacing[6],
borderTopRightRadius: theme.spacing[6],
height: '80%',
},
modalInner: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: theme.spacing[6],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
headerTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
},
newButtonContainer: {
padding: theme.spacing[4],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
newButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: theme.spacing[2],
backgroundColor: theme.colors.muted,
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
},
newButtonText: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.semibold,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: theme.spacing[4] * 5,
},
loadingContainer: {
padding: theme.spacing[8],
alignItems: 'center',
},
loadingText: {
color: theme.colors.mutedForeground,
},
emptyContainer: {
padding: theme.spacing[8],
alignItems: 'center',
},
emptyText: {
color: theme.colors.mutedForeground,
},
conversationItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: theme.spacing[4],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
backgroundColor: 'transparent',
},
conversationItemActive: {
backgroundColor: theme.colors.muted,
},
conversationContent: {
flex: 1,
},
conversationTitle: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.semibold,
fontSize: theme.fontSize.base,
},
conversationDate: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1],
},
deleteButton: {
padding: theme.spacing[2],
},
clearAllContainer: {
padding: theme.spacing[4],
},
clearAllButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: theme.spacing[2],
backgroundColor: 'rgba(127, 29, 29, 0.2)',
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.palette.red[900],
},
clearAllText: {
color: theme.colors.destructive,
fontWeight: theme.fontWeight.semibold,
},
}));
+546
View File
@@ -0,0 +1,546 @@
import { View, Text, Pressable, Animated } from 'react-native';
import { useState, useEffect, useRef } from 'react';
import Markdown from 'react-native-markdown-display';
import { Circle, Info, CheckCircle, XCircle, FileText, ChevronRight, ChevronDown, RefreshCw } from 'lucide-react-native';
import { StyleSheet } from 'react-native-unistyles';
interface UserMessageProps {
message: string;
timestamp: number;
}
const userMessageStylesheet = StyleSheet.create((theme) => ({
container: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginBottom: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
},
bubble: {
backgroundColor: theme.colors.primary,
borderRadius: theme.borderRadius['2xl'],
borderTopRightRadius: theme.borderRadius.sm,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
maxWidth: '80%',
},
text: {
color: theme.colors.primaryForeground,
fontSize: theme.fontSize.lg,
lineHeight: 24,
},
}));
export function UserMessage({ message, timestamp }: UserMessageProps) {
return (
<View style={userMessageStylesheet.container}>
<View style={userMessageStylesheet.bubble}>
<Text style={userMessageStylesheet.text}>{message}</Text>
</View>
</View>
);
}
interface AssistantMessageProps {
message: string;
timestamp: number;
isStreaming?: boolean;
}
const assistantMessageStylesheet = StyleSheet.create((theme) => ({
container: {
marginBottom: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
streamingIndicator: {
marginTop: theme.spacing[1],
},
streamingText: {
color: theme.colors.palette.teal[200],
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.bold,
},
}));
export function AssistantMessage({ message, timestamp, isStreaming = false }: AssistantMessageProps) {
const fadeAnim = useRef(new Animated.Value(0.3)).current;
useEffect(() => {
if (isStreaming) {
Animated.loop(
Animated.sequence([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 600,
useNativeDriver: true,
}),
Animated.timing(fadeAnim, {
toValue: 0.3,
duration: 600,
useNativeDriver: true,
}),
])
).start();
} else {
fadeAnim.stopAnimation();
fadeAnim.setValue(1);
}
}, [isStreaming, fadeAnim]);
const markdownStyles = {
body: {
color: '#f0fdfa',
fontSize: 16,
lineHeight: 24,
},
paragraph: {
marginTop: 0,
marginBottom: 8,
},
strong: {
fontWeight: '700' as const,
},
em: {
fontStyle: 'italic' as const,
},
code_inline: {
backgroundColor: '#134e4a',
color: '#ccfbf1',
paddingHorizontal: 4,
paddingVertical: 2,
borderRadius: 3,
fontFamily: 'monospace',
},
code_block: {
backgroundColor: '#134e4a',
color: '#ccfbf1',
padding: 12,
borderRadius: 6,
fontFamily: 'monospace',
fontSize: 14,
},
fence: {
backgroundColor: '#134e4a',
color: '#ccfbf1',
padding: 12,
borderRadius: 6,
fontFamily: 'monospace',
fontSize: 14,
},
link: {
color: '#5eead4',
textDecorationLine: 'underline' as const,
},
bullet_list: {
marginBottom: 8,
},
ordered_list: {
marginBottom: 8,
},
list_item: {
marginBottom: 4,
},
};
return (
<View style={assistantMessageStylesheet.container}>
<Markdown style={markdownStyles}>{message}</Markdown>
{isStreaming && (
<Animated.View style={[assistantMessageStylesheet.streamingIndicator, { opacity: fadeAnim }]}>
<Text style={assistantMessageStylesheet.streamingText}>...</Text>
</Animated.View>
)}
</View>
);
}
interface ActivityLogProps {
type: 'system' | 'info' | 'success' | 'error' | 'artifact';
message: string;
timestamp: number;
metadata?: Record<string, unknown>;
artifactId?: string;
artifactType?: string;
title?: string;
onArtifactClick?: (artifactId: string) => void;
}
const activityLogStylesheet = StyleSheet.create((theme) => ({
pressable: {
marginHorizontal: theme.spacing[2],
marginBottom: theme.spacing[1],
borderRadius: theme.borderRadius.md,
overflow: 'hidden',
},
pressableActive: {
opacity: 0.7,
},
systemBg: {
backgroundColor: 'rgba(39, 39, 42, 0.5)',
},
infoBg: {
backgroundColor: 'rgba(30, 58, 138, 0.3)',
},
successBg: {
backgroundColor: 'rgba(20, 83, 45, 0.3)',
},
errorBg: {
backgroundColor: 'rgba(127, 29, 29, 0.3)',
},
artifactBg: {
backgroundColor: 'rgba(30, 58, 138, 0.4)',
},
content: {
paddingHorizontal: theme.spacing[3],
paddingVertical: 10,
},
row: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: theme.spacing[2],
},
iconContainer: {
flexShrink: 0,
},
textContainer: {
flex: 1,
},
messageText: {
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
detailsRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: theme.spacing[1],
},
detailsText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
marginRight: theme.spacing[1],
},
metadataContainer: {
marginTop: theme.spacing[2],
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: theme.borderRadius.base,
padding: theme.spacing[2],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
metadataText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontFamily: 'monospace',
lineHeight: 16,
},
}));
export function ActivityLog({
type,
message,
timestamp,
metadata,
artifactId,
artifactType,
title,
onArtifactClick
}: ActivityLogProps) {
const [isExpanded, setIsExpanded] = useState(false);
const typeConfig = {
system: { bg: activityLogStylesheet.systemBg, color: '#a1a1aa', Icon: Circle },
info: { bg: activityLogStylesheet.infoBg, color: '#60a5fa', Icon: Info },
success: { bg: activityLogStylesheet.successBg, color: '#4ade80', Icon: CheckCircle },
error: { bg: activityLogStylesheet.errorBg, color: '#f87171', Icon: XCircle },
artifact: { bg: activityLogStylesheet.artifactBg, color: '#93c5fd', Icon: FileText },
};
const config = typeConfig[type];
const IconComponent = config.Icon;
const handlePress = () => {
if (type === 'artifact' && artifactId && onArtifactClick) {
onArtifactClick(artifactId);
} else if (metadata) {
setIsExpanded(!isExpanded);
}
};
const displayMessage = type === 'artifact' && artifactType && title
? `${artifactType}: ${title}`
: message;
const isInteractive = type === 'artifact' || metadata;
return (
<Pressable
onPress={handlePress}
disabled={!isInteractive}
style={[
activityLogStylesheet.pressable,
config.bg,
isInteractive && activityLogStylesheet.pressableActive,
]}
>
<View style={activityLogStylesheet.content}>
<View style={activityLogStylesheet.row}>
<View style={activityLogStylesheet.iconContainer}>
<IconComponent size={16} color={config.color} />
</View>
<View style={activityLogStylesheet.textContainer}>
<Text style={[activityLogStylesheet.messageText, { color: config.color }]}>
{displayMessage}
</Text>
{metadata && (
<View style={activityLogStylesheet.detailsRow}>
<Text style={activityLogStylesheet.detailsText}>Details</Text>
{isExpanded ? (
<ChevronDown size={12} color="#71717a" />
) : (
<ChevronRight size={12} color="#71717a" />
)}
</View>
)}
</View>
</View>
{isExpanded && metadata && (
<View style={activityLogStylesheet.metadataContainer}>
<Text style={activityLogStylesheet.metadataText}>
{JSON.stringify(metadata, null, 2)}
</Text>
</View>
)}
</View>
</Pressable>
);
}
interface ToolCallProps {
toolName: string;
args: any;
result?: any;
error?: any;
status: 'executing' | 'completed' | 'failed';
}
const toolCallStylesheet = StyleSheet.create((theme) => ({
pressable: {
marginHorizontal: theme.spacing[2],
marginBottom: theme.spacing[2],
backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
overflow: 'hidden',
},
pressableActive: {
opacity: 0.8,
},
executingBorder: {
borderColor: theme.colors.palette.amber[500],
},
completedBorder: {
borderColor: theme.colors.palette.green[500],
},
failedBorder: {
borderColor: theme.colors.destructive,
},
content: {
padding: theme.spacing[3],
},
headerRow: {
flexDirection: 'row',
alignItems: 'center',
},
chevronContainer: {
marginRight: theme.spacing[2],
},
toolName: {
color: theme.colors.foreground,
fontFamily: 'monospace',
fontWeight: theme.fontWeight.semibold,
fontSize: theme.fontSize.sm,
flex: 1,
},
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.base,
},
executingBadgeBg: {
backgroundColor: 'rgba(245, 158, 11, 0.2)',
},
completedBadgeBg: {
backgroundColor: 'rgba(34, 197, 94, 0.2)',
},
failedBadgeBg: {
backgroundColor: 'rgba(239, 68, 68, 0.2)',
},
statusText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
textTransform: 'uppercase',
},
expandedContent: {
marginTop: theme.spacing[3],
gap: theme.spacing[2],
},
section: {
// empty - just for grouping
},
sectionTitle: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 6,
},
errorSectionTitle: {
color: theme.colors.palette.red[300],
},
sectionContent: {
backgroundColor: theme.colors.background,
borderRadius: theme.borderRadius.base,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
padding: theme.spacing[2],
},
errorSectionContent: {
borderColor: theme.colors.palette.red[800],
},
sectionText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontFamily: 'monospace',
lineHeight: 16,
},
}));
export function ToolCall({ toolName, args, result, error, status }: ToolCallProps) {
const [isExpanded, setIsExpanded] = useState(false);
const spinAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (status === 'executing') {
Animated.loop(
Animated.timing(spinAnim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
})
).start();
} else {
spinAnim.stopAnimation();
spinAnim.setValue(0);
}
}, [status, spinAnim]);
const spin = spinAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
});
const statusConfig = {
executing: {
border: toolCallStylesheet.executingBorder,
badgeBg: toolCallStylesheet.executingBadgeBg,
color: '#fcd34d',
label: 'executing',
},
completed: {
border: toolCallStylesheet.completedBorder,
badgeBg: toolCallStylesheet.completedBadgeBg,
color: '#86efac',
label: 'completed',
},
failed: {
border: toolCallStylesheet.failedBorder,
badgeBg: toolCallStylesheet.failedBadgeBg,
color: '#fca5a5',
label: 'failed',
},
};
const config = statusConfig[status];
return (
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
style={[toolCallStylesheet.pressable, toolCallStylesheet.pressableActive, config.border]}
>
<View style={toolCallStylesheet.content}>
<View style={toolCallStylesheet.headerRow}>
<View style={toolCallStylesheet.chevronContainer}>
{isExpanded ? (
<ChevronDown size={16} color="#9ca3af" />
) : (
<ChevronRight size={16} color="#9ca3af" />
)}
</View>
<Text style={toolCallStylesheet.toolName}>
{toolName}
</Text>
<View style={[toolCallStylesheet.statusBadge, config.badgeBg]}>
{status === 'executing' ? (
<Animated.View style={{ transform: [{ rotate: spin }] }}>
<RefreshCw size={14} color={config.color} />
</Animated.View>
) : status === 'completed' ? (
<CheckCircle size={14} color={config.color} />
) : (
<XCircle size={14} color={config.color} />
)}
<Text style={[toolCallStylesheet.statusText, { color: config.color }]}>
{config.label}
</Text>
</View>
</View>
{isExpanded && (
<View style={toolCallStylesheet.expandedContent}>
<View style={toolCallStylesheet.section}>
<Text style={toolCallStylesheet.sectionTitle}>
Arguments
</Text>
<View style={toolCallStylesheet.sectionContent}>
<Text style={toolCallStylesheet.sectionText}>
{JSON.stringify(args, null, 2)}
</Text>
</View>
</View>
{result !== undefined && (
<View style={toolCallStylesheet.section}>
<Text style={toolCallStylesheet.sectionTitle}>
Result
</Text>
<View style={toolCallStylesheet.sectionContent}>
<Text style={toolCallStylesheet.sectionText}>
{JSON.stringify(result, null, 2)}
</Text>
</View>
</View>
)}
{error !== undefined && (
<View style={toolCallStylesheet.section}>
<Text style={[toolCallStylesheet.sectionTitle, toolCallStylesheet.errorSectionTitle]}>
Error
</Text>
<View style={[toolCallStylesheet.sectionContent, toolCallStylesheet.errorSectionContent]}>
<Text style={toolCallStylesheet.sectionText}>
{JSON.stringify(error, null, 2)}
</Text>
</View>
</View>
)}
</View>
)}
</View>
</Pressable>
);
}
@@ -0,0 +1,224 @@
import { Pressable, View, Text, Animated } from 'react-native';
import { useEffect, useRef } from 'react';
import { StyleSheet } from 'react-native-unistyles';
interface VoiceButtonProps {
state: 'idle' | 'recording' | 'processing' | 'playing';
onPress: () => void;
disabled?: boolean;
}
const styles = StyleSheet.create((theme) => ({
container: {
alignItems: 'center',
gap: theme.spacing[4],
},
pressable: {
opacity: 1,
},
pressableDisabled: {
opacity: 0.5,
},
button: {
width: 80,
height: 80,
borderRadius: theme.borderRadius.full,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
buttonIdle: {
backgroundColor: theme.colors.muted,
},
buttonRecording: {
backgroundColor: theme.colors.destructive,
},
buttonProcessing: {
backgroundColor: theme.colors.primary,
},
buttonPlaying: {
backgroundColor: theme.colors.palette.green[500],
},
label: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
// Recording icon
recordingIcon: {
width: 24,
height: 24,
backgroundColor: theme.colors.foreground,
borderRadius: theme.borderRadius.md,
},
// Processing icon
processingIconContainer: {
width: 24,
height: 24,
},
processingDot: {
width: 6,
height: 6,
backgroundColor: theme.colors.primaryForeground,
borderRadius: theme.borderRadius.full,
position: 'absolute',
},
processingDotTop: {
top: 0,
left: 12,
},
processingDotRight: {
top: 12,
right: 0,
},
processingDotBottom: {
bottom: 0,
left: 12,
},
// Playing icon
playingIconContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[1],
},
playingBar: {
backgroundColor: theme.colors.foreground,
borderRadius: theme.borderRadius.sm,
},
playingBar1: {
width: 4,
height: 16,
},
playingBar2: {
width: 4,
height: 24,
},
playingBar3: {
width: 4,
height: 12,
},
// Idle icon (microphone)
micContainer: {
width: 24,
height: 32,
position: 'relative',
},
micCapsule: {
position: 'absolute',
bottom: 0,
left: 4,
width: 16,
height: 24,
backgroundColor: theme.colors.foreground,
borderTopLeftRadius: 999,
borderTopRightRadius: 999,
},
micBase: {
position: 'absolute',
bottom: 0,
left: 0,
width: 24,
height: 6,
backgroundColor: theme.colors.foreground,
borderRadius: theme.borderRadius.full,
},
}));
export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonProps) {
const pulseAnim = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (state === 'recording') {
Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.2,
duration: 800,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
])
).start();
} else {
pulseAnim.setValue(1);
}
}, [state, pulseAnim]);
const getButtonStyle = () => {
switch (state) {
case 'recording':
return styles.buttonRecording;
case 'processing':
return styles.buttonProcessing;
case 'playing':
return styles.buttonPlaying;
default:
return styles.buttonIdle;
}
};
const getIcon = () => {
switch (state) {
case 'recording':
return <View style={styles.recordingIcon} />;
case 'processing':
return (
<View style={styles.processingIconContainer}>
<View style={[styles.processingDot, styles.processingDotTop]} />
<View style={[styles.processingDot, styles.processingDotRight]} />
<View style={[styles.processingDot, styles.processingDotBottom]} />
</View>
);
case 'playing':
return (
<View style={styles.playingIconContainer}>
<View style={[styles.playingBar, styles.playingBar1]} />
<View style={[styles.playingBar, styles.playingBar2]} />
<View style={[styles.playingBar, styles.playingBar3]} />
</View>
);
default:
return (
<View style={styles.micContainer}>
<View style={styles.micCapsule} />
<View style={styles.micBase} />
</View>
);
}
};
const getLabel = () => {
switch (state) {
case 'recording':
return 'Recording...';
case 'processing':
return 'Processing...';
case 'playing':
return 'Playing...';
default:
return 'Tap to speak';
}
};
return (
<View style={styles.container}>
<Pressable
onPress={onPress}
disabled={disabled}
style={disabled ? styles.pressableDisabled : styles.pressable}
>
<Animated.View style={[styles.button, getButtonStyle(), { transform: [{ scale: pulseAnim }] }]}>
{getIcon()}
</Animated.View>
</Pressable>
<Text style={styles.label}>{getLabel()}</Text>
</View>
);
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/
import { Platform } from 'react-native';
const tintColorLight = '#0a7ea4';
const tintColorDark = '#fff';
export const Colors = {
light: {
text: '#11181C',
background: '#fff',
tint: tintColorLight,
icon: '#687076',
tabIconDefault: '#687076',
tabIconSelected: tintColorLight,
},
dark: {
text: '#ECEDEE',
background: '#151718',
tint: tintColorDark,
icon: '#9BA1A6',
tabIconDefault: '#9BA1A6',
tabIconSelected: tintColorDark,
},
};
export const Fonts = Platform.select({
ios: {
/** iOS `UIFontDescriptorSystemDesignDefault` */
sans: 'system-ui',
/** iOS `UIFontDescriptorSystemDesignSerif` */
serif: 'ui-serif',
/** iOS `UIFontDescriptorSystemDesignRounded` */
rounded: 'ui-rounded',
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
mono: 'ui-monospace',
},
default: {
sans: 'normal',
serif: 'serif',
rounded: 'normal',
mono: 'monospace',
},
web: {
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
serif: "Georgia, 'Times New Roman', serif",
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
},
});
+183
View File
@@ -0,0 +1,183 @@
import { useState, useRef, useEffect } from 'react';
import { initialize, playPCMData } from '@speechmatics/expo-two-way-audio';
interface QueuedAudio {
audioData: Blob;
resolve: (duration: number) => void;
reject: (error: Error) => void;
}
/**
* Resample PCM16 audio from 24kHz to 16kHz
* OpenAI returns 24kHz, Speechmatics expects 16kHz
*/
function resamplePcm24kTo16k(pcm24k: Uint8Array): Uint8Array {
// PCM16 = 2 bytes per sample
const samples24k = pcm24k.length / 2;
const samples16k = Math.floor(samples24k * 16000 / 24000);
const pcm16k = new Uint8Array(samples16k * 2);
const ratio = 24000 / 16000; // 1.5
for (let i = 0; i < samples16k; i++) {
const srcIndex = Math.floor(i * ratio) * 2;
if (srcIndex + 1 < pcm24k.length) {
pcm16k[i * 2] = pcm24k[srcIndex];
pcm16k[i * 2 + 1] = pcm24k[srcIndex + 1];
}
}
console.log('[AudioPlayer] Resampled PCM:', {
input24k: pcm24k.length,
output16k: pcm16k.length,
durationMs: (samples16k / 16),
});
return pcm16k;
}
/**
* Hook for audio playback using Speechmatics two-way audio with echo cancellation
*/
export function useAudioPlayer() {
const [isPlaying, setIsPlaying] = useState(false);
const [audioInitialized, setAudioInitialized] = useState(false);
const queueRef = useRef<QueuedAudio[]>([]);
const isProcessingQueueRef = useRef(false);
// Initialize Speechmatics audio on mount
useEffect(() => {
const initializeAudio = async () => {
try {
await initialize();
setAudioInitialized(true);
console.log('[AudioPlayer] ✅ Initialized (Speechmatics two-way audio)');
} catch (error) {
console.error('[AudioPlayer] Failed to initialize audio:', error);
}
};
initializeAudio();
}, []);
async function play(audioData: Blob): Promise<number> {
return new Promise((resolve, reject) => {
// Add to queue with its promise handlers
queueRef.current.push({ audioData, resolve, reject });
// Start processing queue if not already processing
if (!isProcessingQueueRef.current) {
processQueue();
}
});
}
async function processQueue(): Promise<void> {
if (isProcessingQueueRef.current || queueRef.current.length === 0) {
return;
}
isProcessingQueueRef.current = true;
while (queueRef.current.length > 0) {
const item = queueRef.current.shift()!;
try {
const duration = await playAudio(item.audioData);
item.resolve(duration);
} catch (error) {
item.reject(error as Error);
}
}
isProcessingQueueRef.current = false;
}
async function processNextInQueue(): Promise<void> {
if (queueRef.current.length > 0) {
const item = queueRef.current.shift()!;
try {
const duration = await playAudio(item.audioData);
item.resolve(duration);
} catch (error) {
item.reject(error as Error);
}
} else {
isProcessingQueueRef.current = false;
}
}
async function playAudio(audioData: Blob): Promise<number> {
return new Promise(async (resolve, reject) => {
try {
console.log(
`[AudioPlayer] Playing audio (${audioData.size} bytes, type: ${audioData.type})`
);
if (!audioInitialized) {
throw new Error('Audio not initialized');
}
// Get PCM data from blob (server now sends PCM format)
const arrayBuffer = await audioData.arrayBuffer();
let pcm24k = new Uint8Array(arrayBuffer);
// Resample from 24kHz (OpenAI) to 16kHz (Speechmatics)
const pcm16k = resamplePcm24kTo16k(pcm24k);
// Calculate total duration
const samples = pcm16k.length / 2; // 16-bit = 2 bytes per sample
const durationSec = samples / 16000; // 16kHz sample rate
const audioSizeKb = (pcm16k.length / 1024).toFixed(2);
console.log('[AudioPlayer] 🔊 Playing audio:', audioSizeKb, 'KB, duration:', durationSec.toFixed(2), 's');
setIsPlaying(true);
// Play entire PCM data at once through Speechmatics
playPCMData(pcm16k);
// Wait for playback to finish (estimate based on duration)
setTimeout(() => {
console.log('[AudioPlayer] ✅ Playback finished');
setIsPlaying(false);
resolve(durationSec);
}, durationSec * 1000);
} catch (error) {
console.error('[AudioPlayer] Error playing audio:', error);
setIsPlaying(false);
reject(error);
}
});
}
function stop(): void {
if (isPlaying) {
console.log('[AudioPlayer] 🛑 Stopping playback (interrupted)');
}
setIsPlaying(false);
// Reject all pending promises in the queue
while (queueRef.current.length > 0) {
const item = queueRef.current.shift()!;
item.reject(new Error('Playback stopped'));
}
isProcessingQueueRef.current = false;
}
function clearQueue(): void {
// Reject all pending promises in the queue
while (queueRef.current.length > 0) {
const item = queueRef.current.shift()!;
item.reject(new Error('Queue cleared'));
}
}
return {
play,
stop,
isPlaying: () => isPlaying,
clearQueue,
};
}
@@ -0,0 +1,300 @@
import { useAudioRecorder as useExpoAudioRecorder, useAudioRecorderState, RecordingOptions, RecordingPresets, requestRecordingPermissionsAsync, setAudioModeAsync } from 'expo-audio';
import { Paths, File, Directory, FileInfo } from 'expo-file-system';
import { useState, useEffect } from 'react';
export interface AudioCaptureConfig {
sampleRate?: number;
numberOfChannels?: number;
bitRate?: number;
onAudioLevel?: (level: number) => void;
onSpeechSegment?: (audioBlob: Blob) => void;
enableContinuousRecording?: boolean;
}
/**
* Workaround for Expo SDK 54 Android bug where audioRecorder.uri returns empty/zero-byte file
* https://github.com/expo/expo/issues/39646
*/
async function getActualRecordingUri(createdAt: Date): Promise<string | null> {
try {
console.log('[AudioRecorder] Searching for recording file created at:', createdAt.toISOString());
const audioDir = new Directory(Paths.cache, 'Audio');
console.log('[AudioRecorder] Audio cache directory URI:', audioDir.uri);
console.log('[AudioRecorder] Directory exists:', audioDir.exists);
if (!audioDir.exists) {
console.log('[AudioRecorder] Audio cache directory does not exist');
return null;
}
const files = audioDir.list();
console.log('[AudioRecorder] Found files in Audio cache:', files.length);
if (!files.length) {
console.log('[AudioRecorder] No files found in Audio cache directory');
return null;
}
const validFiles = files
.map(file => {
const info = file.info();
console.log('[AudioRecorder] File info:', {
uri: info.uri,
size: info.size,
creationTime: info.creationTime ? new Date(info.creationTime).toISOString() : null,
});
return info;
})
.filter(f => f.size && f.size > 0);
console.log('[AudioRecorder] Valid files (size > 0):', validFiles.length);
if (validFiles.length === 0) {
console.log('[AudioRecorder] No valid files found (all are zero-byte)');
return null;
}
let closest: FileInfo | null = null;
let minDiff = Infinity;
for (const file of validFiles) {
if (!file.creationTime || !file.uri) continue;
const diff = Math.abs(file.creationTime - createdAt.getTime());
console.log('[AudioRecorder] Time diff for file:', {
uri: file.uri,
diffMs: diff,
});
if (diff < minDiff) {
closest = file;
minDiff = diff;
}
}
if (closest) {
const resultUri = closest.uri?.slice(0, -1) ?? null;
console.log('[AudioRecorder] Found closest file:', {
uri: resultUri,
size: closest.size,
timeDiffMs: minDiff,
});
return resultUri;
}
console.log('[AudioRecorder] No closest file found');
return null;
} catch (e) {
console.error('[AudioRecorder] Error finding actual recording file:', e);
return null;
}
}
/**
* Convert audio file URI to Blob for WebSocket transmission
* Returns a Blob-like object that works in React Native
*/
async function uriToBlob(uri: string): Promise<Blob> {
// Use expo-file-system to read the file
const file = new File(uri);
const base64 = await file.base64();
const size = file.size;
// React Native doesn't support creating Blobs from binary data
// Create a Blob-like object that has the methods we need
const blobLike = {
type: 'audio/m4a',
size: size,
arrayBuffer: async () => {
// Convert base64 to ArrayBuffer
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
},
} as Blob;
return blobLike;
}
/**
* Hook for audio recording with configuration matching web version
* Matches the web app's audio constraints:
* - 16000 sample rate (optimal for speech/Whisper)
* - 1 channel (mono)
* - Echo cancellation, noise suppression, auto gain control (voice_communication on Android)
*/
export function useAudioRecorder(config?: AudioCaptureConfig) {
const [isRecording, setIsRecording] = useState(false);
const [recordingStartTime, setRecordingStartTime] = useState<Date | null>(null);
// Create recording options matching web app constraints
const recordingOptions: RecordingOptions = {
...RecordingPresets.HIGH_QUALITY,
sampleRate: config?.sampleRate || 16000,
numberOfChannels: config?.numberOfChannels || 1,
bitRate: config?.bitRate || 128000,
extension: '.m4a',
isMeteringEnabled: !!config?.onAudioLevel, // Enable metering if callback provided
android: {
extension: '.m4a',
outputFormat: 'mpeg4',
audioEncoder: 'aac',
sampleRate: config?.sampleRate || 16000,
audioSource: 'voice_communication', // Enables echo cancellation, noise suppression, auto gain control
},
ios: {
extension: '.m4a',
audioQuality: 127, // High quality
sampleRate: config?.sampleRate || 16000,
linearPCMBitDepth: 16,
linearPCMIsBigEndian: false,
linearPCMIsFloat: false,
},
web: {
mimeType: 'audio/webm;codecs=opus',
bitsPerSecond: config?.bitRate || 128000,
},
};
const audioRecorder = useExpoAudioRecorder(recordingOptions);
const recorderState = useAudioRecorderState(audioRecorder, 100);
// Monitor audio levels if metering is enabled
useEffect(() => {
if (!config?.onAudioLevel || !isRecording) return;
const interval = setInterval(() => {
const metering = recorderState.metering;
if (metering !== undefined && metering !== null) {
// Normalize metering value (typically ranges from -160 to 0 dB)
// Convert to 0-1 range where 0 is silence and 1 is loud
// We'll use -40 dB as the threshold for "loud"
const normalized = Math.max(0, Math.min(1, (metering + 40) / 40));
config.onAudioLevel?.(normalized);
}
}, 100); // Check every 100ms
return () => clearInterval(interval);
}, [isRecording, config, recorderState.metering]);
async function start(): Promise<void> {
if (isRecording) {
throw new Error('Already recording');
}
try {
// Request microphone permissions
console.log('[AudioRecorder] Requesting recording permissions...');
const permissionResponse = await requestRecordingPermissionsAsync();
if (!permissionResponse.granted) {
throw new Error('Microphone permission denied. Please enable microphone access in your device settings.');
}
// Configure audio mode for recording
console.log('[AudioRecorder] Configuring audio mode...');
await setAudioModeAsync({
playsInSilentMode: true,
allowsRecording: true,
});
console.log('[AudioRecorder] Starting recording with options:', {
sampleRate: recordingOptions.sampleRate,
numberOfChannels: recordingOptions.numberOfChannels,
bitRate: recordingOptions.bitRate,
});
const startTime = new Date();
setRecordingStartTime(startTime);
// Prepare the recorder before recording (required step)
console.log('[AudioRecorder] Preparing recorder...');
await audioRecorder.prepareToRecordAsync();
console.log('[AudioRecorder] Starting recording...');
await audioRecorder.record();
setIsRecording(true);
console.log('[AudioRecorder] Recording started at:', startTime.toISOString());
console.log('[AudioRecorder] Recorder isRecording:', audioRecorder.isRecording);
} catch (error: any) {
console.error('[AudioRecorder] Failed to start recording:', error);
throw new Error(`Failed to start audio recording: ${error.message}`);
}
}
async function stop(): Promise<Blob> {
if (!isRecording) {
throw new Error('Not recording');
}
try {
// Stop recording
await audioRecorder.stop();
setIsRecording(false);
// Get URI from recorder
let uri = audioRecorder.uri;
console.log('[AudioRecorder] Initial URI from recorder:', uri);
// Workaround for Expo SDK 54 Android bug - find actual recording file
if (recordingStartTime && (!uri || uri === '')) {
console.log('[AudioRecorder] Using workaround to find actual recording file...');
const actualUri = await getActualRecordingUri(recordingStartTime);
if (actualUri) {
uri = actualUri;
console.log('[AudioRecorder] Found actual recording URI:', uri);
}
}
if (!uri || uri === '') {
throw new Error('No recording URI found');
}
// Get file info
const file = new File(uri);
const exists = file.exists;
console.log('[AudioRecorder] File exists:', exists);
if (!exists) {
throw new Error('Recording file does not exist');
}
// Convert URI to Blob
const audioBlob = await uriToBlob(uri);
console.log('[AudioRecorder] Recording converted to blob:', {
size: audioBlob.size,
type: audioBlob.type,
});
// Clean up the temporary file
file.delete();
// Reset start time
setRecordingStartTime(null);
return audioBlob;
} catch (error: any) {
setIsRecording(false);
setRecordingStartTime(null);
console.error('[AudioRecorder] Failed to stop recording:', error);
throw new Error(`Failed to stop audio recording: ${error.message}`);
}
}
function getSupportedMimeType(): string | null {
// On native platforms, expo-audio uses m4a/AAC
// On web, it can use webm/opus
return 'audio/m4a';
}
return {
start,
stop,
isRecording: () => isRecording,
getSupportedMimeType,
};
}
@@ -0,0 +1 @@
export { useColorScheme } from 'react-native';
@@ -0,0 +1,21 @@
import { useEffect, useState } from 'react';
import { useColorScheme as useRNColorScheme } from 'react-native';
/**
* To support static rendering, this value needs to be re-calculated on the client side for web
*/
export function useColorScheme() {
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => {
setHasHydrated(true);
}, []);
const colorScheme = useRNColorScheme();
if (hasHydrated) {
return colorScheme;
}
return 'light';
}
+78
View File
@@ -0,0 +1,78 @@
import { useState, useEffect, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
const STORAGE_KEY = '@voice-dev:settings';
export interface Settings {
serverUrl: string;
useSpeaker: boolean;
keepScreenOn: boolean;
theme: 'dark' | 'light' | 'auto';
}
const DEFAULT_SETTINGS: Settings = {
serverUrl: 'wss://mohameds-macbook-pro.tail8fe838.ts.net/ws',
useSpeaker: true,
keepScreenOn: true,
theme: 'dark',
};
export interface UseSettingsReturn {
settings: Settings;
isLoading: boolean;
updateSettings: (updates: Partial<Settings>) => Promise<void>;
resetSettings: () => Promise<void>;
}
export function useSettings(): UseSettingsReturn {
const [settings, setSettings] = useState<Settings>(DEFAULT_SETTINGS);
const [isLoading, setIsLoading] = useState(true);
// Load settings from AsyncStorage on mount
useEffect(() => {
loadSettings();
}, []);
async function loadSettings() {
try {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored) as Partial<Settings>;
setSettings({ ...DEFAULT_SETTINGS, ...parsed });
}
} catch (error) {
console.error('[Settings] Failed to load settings:', error);
// Continue with default settings
} finally {
setIsLoading(false);
}
}
const updateSettings = useCallback(async (updates: Partial<Settings>) => {
try {
const newSettings = { ...settings, ...updates };
setSettings(newSettings);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newSettings));
} catch (error) {
console.error('[Settings] Failed to save settings:', error);
throw error;
}
}, [settings]);
const resetSettings = useCallback(async () => {
try {
setSettings(DEFAULT_SETTINGS);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_SETTINGS));
} catch (error) {
console.error('[Settings] Failed to reset settings:', error);
throw error;
}
}, []);
return {
settings,
isLoading,
updateSettings,
resetSettings,
};
}
@@ -0,0 +1,352 @@
import { useState, useEffect, useRef, useCallback } from "react";
import {
initialize,
toggleRecording,
tearDown,
useExpoTwoWayAudioEventListener,
type MicrophoneDataCallback,
type VolumeLevelCallback,
} from "@speechmatics/expo-two-way-audio";
export interface SpeechmaticsAudioConfig {
onAudioSegment?: (audioData: string) => void;
onSpeechStart?: () => void;
onSpeechEnd?: () => void;
onError?: (error: Error) => void;
volumeThreshold: number; // Volume threshold for speech detection (0-1)
silenceDuration: number; // ms of silence before ending segment
speechConfirmationDuration: number; // ms of sustained speech before confirming
detectionGracePeriod: number; // ms grace period for volume dips during detection
}
export interface SpeechmaticsAudio {
start: () => Promise<void>;
stop: () => Promise<void>;
isActive: boolean;
isSpeaking: boolean;
isDetecting: boolean;
}
/**
* Convert raw PCM data to WAV format by adding WAV headers
*/
function pcmToWav(
pcmBase64: string,
sampleRate: number = 16000,
channels: number = 1,
bitsPerSample: number = 16
): string {
const pcmBinary = atob(pcmBase64);
const pcmLength = pcmBinary.length;
const byteRate = sampleRate * channels * (bitsPerSample / 8);
const blockAlign = channels * (bitsPerSample / 8);
const dataSize = pcmLength;
const fileSize = 44 + dataSize;
const wavBuffer = new Uint8Array(fileSize);
const view = new DataView(wavBuffer.buffer);
let offset = 0;
// RIFF chunk descriptor
writeString(view, offset, "RIFF");
offset += 4;
view.setUint32(offset, fileSize - 8, true);
offset += 4;
writeString(view, offset, "WAVE");
offset += 4;
// fmt sub-chunk
writeString(view, offset, "fmt ");
offset += 4;
view.setUint32(offset, 16, true);
offset += 4;
view.setUint16(offset, 1, true);
offset += 2;
view.setUint16(offset, channels, true);
offset += 2;
view.setUint32(offset, sampleRate, true);
offset += 4;
view.setUint32(offset, byteRate, true);
offset += 4;
view.setUint16(offset, blockAlign, true);
offset += 2;
view.setUint16(offset, bitsPerSample, true);
offset += 2;
// data sub-chunk
writeString(view, offset, "data");
offset += 4;
view.setUint32(offset, dataSize, true);
offset += 4;
// Copy PCM data
for (let i = 0; i < pcmLength; i++) {
wavBuffer[offset + i] = pcmBinary.charCodeAt(i);
}
// Convert to base64
let binary = "";
for (let i = 0; i < wavBuffer.length; i++) {
binary += String.fromCharCode(wavBuffer[i]);
}
return btoa(binary);
}
function writeString(view: DataView, offset: number, str: string): void {
for (let i = 0; i < str.length; i++) {
view.setUint8(offset + i, str.charCodeAt(i));
}
}
function uint8ArrayToBase64(bytes: Uint8Array): string {
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function concatenateUint8Arrays(arrays: Uint8Array[]): Uint8Array {
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const arr of arrays) {
result.set(arr, offset);
offset += arr.length;
}
return result;
}
/**
* Hook for audio capture with echo cancellation using Speechmatics expo-two-way-audio
*/
export function useSpeechmaticsAudio(
config: SpeechmaticsAudioConfig
): SpeechmaticsAudio {
const [isActive, setIsActive] = useState(false);
const [isSpeaking, setIsSpeaking] = useState(false);
const [isDetecting, setIsDetecting] = useState(false);
const [audioInitialized, setAudioInitialized] = useState(false);
const audioBufferRef = useRef<Uint8Array[]>([]);
const silenceStartRef = useRef<number | null>(null);
const isSpeakingRef = useRef(false);
const speechDetectionStartRef = useRef<number | null>(null);
const speechConfirmedRef = useRef(false);
const detectionSilenceStartRef = useRef<number | null>(null);
const VOLUME_THRESHOLD = config.volumeThreshold;
const SILENCE_DURATION_MS = config.silenceDuration;
const SPEECH_CONFIRMATION_MS = config.speechConfirmationDuration;
const DETECTION_GRACE_PERIOD_MS = config.detectionGracePeriod;
// Initialize audio on mount
useEffect(() => {
const initializeAudio = async () => {
try {
await initialize();
setAudioInitialized(true);
console.log("[SpeechmaticsAudio] Audio initialized");
} catch (error) {
console.error("[SpeechmaticsAudio] Initialization error:", error);
const err = error instanceof Error ? error : new Error(String(error));
config.onError?.(err);
}
};
initializeAudio();
return () => {
tearDown();
};
}, []);
// Listen to microphone data
useExpoTwoWayAudioEventListener(
"onMicrophoneData",
useCallback<MicrophoneDataCallback>(
(event) => {
if (!isActive) return;
const pcmData: Uint8Array = event.data;
// Buffer the audio chunk if we're detecting or speaking
// Start buffering from first spike to capture beginning of speech
if (speechDetectionStartRef.current !== null || isSpeakingRef.current) {
audioBufferRef.current.push(pcmData);
}
},
[isActive]
)
);
// Listen to volume level for VAD
useExpoTwoWayAudioEventListener(
"onInputVolumeLevelData",
useCallback<VolumeLevelCallback>(
(event) => {
if (!isActive) return;
const volume: number = event.data;
const speechDetected = volume > VOLUME_THRESHOLD;
// console.log('[SpeechmaticsAudio] Volume:', volume.toFixed(6));
if (speechDetected && !isSpeakingRef.current && !speechConfirmedRef.current) {
// Initial speech detection - start tracking
if (speechDetectionStartRef.current === null) {
console.log(
"[SpeechmaticsAudio] Speech started (volume:",
volume.toFixed(4),
")"
);
speechDetectionStartRef.current = Date.now();
detectionSilenceStartRef.current = null;
audioBufferRef.current = [];
setIsDetecting(true);
} else {
// Volume is back above threshold - reset grace period
detectionSilenceStartRef.current = null;
// Check if speech has been sustained long enough
const speechDuration = Date.now() - speechDetectionStartRef.current;
if (speechDuration >= SPEECH_CONFIRMATION_MS) {
// Speech CONFIRMED
console.log(
"[SpeechmaticsAudio] Speech confirmed after",
speechDuration,
"ms"
);
isSpeakingRef.current = true;
speechConfirmedRef.current = true;
silenceStartRef.current = null;
setIsDetecting(false);
setIsSpeaking(true);
config.onSpeechStart?.();
}
}
} else if (speechDetected && isSpeakingRef.current && speechConfirmedRef.current) {
// Continuing confirmed speech
silenceStartRef.current = null;
} else if (!speechDetected && !speechConfirmedRef.current && speechDetectionStartRef.current !== null) {
// Volume dropped during detection phase - apply grace period
if (detectionSilenceStartRef.current === null) {
detectionSilenceStartRef.current = Date.now();
} else {
const graceDuration = Date.now() - detectionSilenceStartRef.current;
if (graceDuration >= DETECTION_GRACE_PERIOD_MS) {
// Grace period expired - cancel detection
console.log(
"[SpeechmaticsAudio] Speech detection cancelled after",
graceDuration,
"ms grace period"
);
speechDetectionStartRef.current = null;
detectionSilenceStartRef.current = null;
audioBufferRef.current = [];
setIsDetecting(false);
}
}
} else if (!speechDetected && isSpeakingRef.current && speechConfirmedRef.current) {
// Potential speech END
if (silenceStartRef.current === null) {
silenceStartRef.current = Date.now();
} else {
const silenceDuration = Date.now() - silenceStartRef.current;
if (silenceDuration >= SILENCE_DURATION_MS) {
// Speech END confirmed
console.log(
"[SpeechmaticsAudio] Speech ended after",
silenceDuration,
"ms silence"
);
isSpeakingRef.current = false;
speechConfirmedRef.current = false;
speechDetectionStartRef.current = null;
silenceStartRef.current = null;
setIsSpeaking(false);
config.onSpeechEnd?.();
// Send buffered audio segment
if (audioBufferRef.current.length > 0) {
const combinedBinary = concatenateUint8Arrays(
audioBufferRef.current
);
const combinedPcmBase64 = uint8ArrayToBase64(combinedBinary);
const wavAudio = pcmToWav(combinedPcmBase64, 16000, 1, 16);
config.onAudioSegment?.(wavAudio);
audioBufferRef.current = [];
}
}
}
}
},
[isActive, VOLUME_THRESHOLD, SILENCE_DURATION_MS, SPEECH_CONFIRMATION_MS, DETECTION_GRACE_PERIOD_MS, config]
)
);
async function start(): Promise<void> {
if (!audioInitialized) {
throw new Error("Audio not initialized");
}
if (isActive) {
console.log("[SpeechmaticsAudio] Already active");
return;
}
try {
console.log("[SpeechmaticsAudio] Starting audio capture...");
// Start recording
toggleRecording(true);
setIsActive(true);
console.log("[SpeechmaticsAudio] Audio capture started successfully");
} catch (error) {
console.error("[SpeechmaticsAudio] Start error:", error);
const err = error instanceof Error ? error : new Error(String(error));
config.onError?.(err);
await stop();
throw error;
}
}
async function stop(): Promise<void> {
console.log("[SpeechmaticsAudio] Stopping audio capture...");
// Stop recording
toggleRecording(false);
// Reset state
audioBufferRef.current = [];
isSpeakingRef.current = false;
speechConfirmedRef.current = false;
speechDetectionStartRef.current = null;
detectionSilenceStartRef.current = null;
silenceStartRef.current = null;
setIsActive(false);
setIsSpeaking(false);
setIsDetecting(false);
console.log("[SpeechmaticsAudio] Audio capture stopped");
}
// Cleanup on unmount
useEffect(() => {
return () => {
if (isActive) {
stop();
}
};
}, [isActive]);
return {
start,
stop,
isActive,
isSpeaking,
isDetecting,
};
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme() ?? 'light';
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}
+161
View File
@@ -0,0 +1,161 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import type {
WSInboundMessage,
WSOutboundMessage,
SessionOutboundMessage
} from '@server/server/messages';
export interface UseWebSocketReturn {
isConnected: boolean;
conversationId: string | null;
send: (message: WSInboundMessage) => void;
on: (type: SessionOutboundMessage['type'], handler: (message: SessionOutboundMessage) => void) => () => void;
sendPing: () => void;
sendUserMessage: (message: string) => void;
}
export function useWebSocket(url: string, conversationId?: string | null): UseWebSocketReturn {
const [isConnected, setIsConnected] = useState(false);
const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const handlersRef = useRef<Map<SessionOutboundMessage['type'], Set<(message: SessionOutboundMessage) => void>>>(new Map());
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
return;
}
try {
// Add conversation ID to URL if provided
const wsUrl = conversationId ? `${url}?conversationId=${conversationId}` : url;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('[WS] Connected to server');
setIsConnected(true);
};
ws.onclose = () => {
console.log('[WS] Disconnected from server');
setIsConnected(false);
// Attempt to reconnect after 3 seconds
reconnectTimeoutRef.current = setTimeout(() => {
console.log('[WS] Attempting to reconnect...');
connect();
}, 3000);
};
ws.onerror = (error) => {
console.error('[WS] Error:', error);
};
ws.onmessage = (event) => {
try {
const wsMessage: WSOutboundMessage = JSON.parse(event.data);
// Only session messages trigger handlers
if (wsMessage.type === 'session') {
const sessionMessage = wsMessage.message;
// console.log(`[WS] Received session message type: ${sessionMessage.type}`);
// Track conversation ID when loaded
if (sessionMessage.type === 'conversation_loaded') {
setCurrentConversationId(sessionMessage.payload.conversationId);
}
// Call all registered handlers for this message type
const handlers = handlersRef.current.get(sessionMessage.type);
if (handlers) {
handlers.forEach((handler) => {
try {
handler(sessionMessage);
} catch (err) {
console.error(`[WS] Error in handler for ${sessionMessage.type}:`, err);
}
});
}
} else {
// pong - just log
console.log(`[WS] Received ${wsMessage.type}`);
}
} catch (err) {
console.error('[WS] Failed to parse message:', err);
}
};
wsRef.current = ws;
} catch (err) {
console.error('[WS] Failed to create WebSocket:', err);
}
}, [url, conversationId]);
useEffect(() => {
connect();
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
}
};
}, [connect]);
const send = useCallback((message: WSInboundMessage) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message));
} else {
console.warn('[WS] Cannot send message - not connected');
}
}, []);
const on = useCallback((
type: SessionOutboundMessage['type'],
handler: (message: SessionOutboundMessage) => void
) => {
if (!handlersRef.current.has(type)) {
handlersRef.current.set(type, new Set());
}
handlersRef.current.get(type)!.add(handler);
// Return cleanup function
return () => {
const handlers = handlersRef.current.get(type);
if (handlers) {
handlers.delete(handler);
if (handlers.size === 0) {
handlersRef.current.delete(type);
}
}
};
}, []);
const sendPing = useCallback(() => {
send({ type: 'ping' });
}, [send]);
const sendUserMessage = useCallback(
(message: string) => {
send({
type: 'session',
message: {
type: 'user_text',
text: message,
},
});
},
[send]
);
return {
isConnected,
conversationId: currentConversationId,
send,
on,
sendPing,
sendUserMessage,
};
}
+225
View File
@@ -0,0 +1,225 @@
const baseColors = {
// Base colors
white: "#ffffff",
black: "#000000",
// Zinc scale (primary gray palette)
zinc: {
50: "#fafafa",
100: "#f4f4f5",
200: "#e4e4e7",
300: "#d4d4d8",
400: "#a1a1aa",
500: "#71717a",
600: "#52525b",
700: "#3f3f46",
800: "#27272a",
900: "#18181b",
},
// Gray scale
gray: {
50: "#f9fafb",
100: "#f3f4f6",
200: "#e5e7eb",
300: "#d1d5db",
400: "#9ca3af",
500: "#6b7280",
600: "#4b5563",
700: "#374151",
800: "#1f2937",
900: "#111827",
},
// Slate scale
slate: {
200: "#e2e8f0",
},
// Blue scale
blue: {
50: "#eff6ff",
100: "#dbeafe",
200: "#bfdbfe",
300: "#93c5fd",
400: "#60a5fa",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
800: "#1e40af",
900: "#1e3a8a",
950: "#172554",
},
// Green scale
green: {
100: "#dcfce7",
200: "#bbf7d0",
400: "#4ade80",
500: "#22c55e",
600: "#16a34a",
800: "#166534",
900: "#14532d",
},
// Red scale
red: {
100: "#fee2e2",
200: "#fecaca",
300: "#fca5a5",
500: "#ef4444",
600: "#dc2626",
800: "#991b1b",
900: "#7f1d1d",
},
// Teal scale
teal: {
200: "#99f6e4",
},
// Amber scale
amber: {
500: "#f59e0b",
},
// Yellow scale
yellow: {
400: "#fbbf24",
},
// Purple scale
purple: {
500: "#a855f7",
600: "#9333ea",
},
// Orange scale
orange: {
500: "#f97316",
600: "#ea580c",
},
} as const;
// Semantic color tokens (shadcn-style)
const lightSemanticColors = {
background: "#ffffff",
foreground: "#09090b",
card: "#ffffff",
cardForeground: "#09090b",
popover: "#ffffff",
popoverForeground: "#09090b",
primary: "#18181b",
primaryForeground: "#fafafa",
secondary: "#f4f4f5",
secondaryForeground: "#18181b",
muted: "#f4f4f5",
mutedForeground: "#71717a",
accent: "#f4f4f5",
accentForeground: "#18181b",
destructive: "#ef4444",
destructiveForeground: "#fafafa",
border: "#e4e4e7",
input: "#e4e4e7",
ring: "#18181b",
} as const;
const darkSemanticColors = {
background: "#09090b",
foreground: "#fafafa",
card: "#09090b",
cardForeground: "#fafafa",
popover: "#09090b",
popoverForeground: "#fafafa",
primary: "#fafafa",
primaryForeground: "#18181b",
secondary: "#27272a",
secondaryForeground: "#fafafa",
muted: "#27272a",
mutedForeground: "#a1a1aa",
accent: "#27272a",
accentForeground: "#fafafa",
destructive: "#7f1d1d",
destructiveForeground: "#fafafa",
border: "#27272a",
input: "#27272a",
ring: "#d4d4d8",
} as const;
const commonTheme = {
spacing: {
0: 0,
1: 4,
2: 8,
3: 12,
4: 16,
6: 24,
8: 32,
12: 48,
16: 64,
20: 80,
24: 96,
32: 128,
},
fontSize: {
xs: 10,
sm: 12,
base: 14,
lg: 16,
xl: 18,
"2xl": 20,
"3xl": 24,
},
fontWeight: {
normal: "normal" as const,
semibold: "600" as const,
bold: "bold" as const,
},
borderRadius: {
none: 0,
sm: 2,
base: 4,
md: 6,
lg: 8,
xl: 12,
"2xl": 16,
full: 9999,
},
borderWidth: {
0: 0,
1: 1,
2: 2,
},
opacity: {
0: 0,
50: 0.5,
100: 1,
},
} as const;
export const darkTheme = {
colors: {
...darkSemanticColors,
palette: baseColors,
},
...commonTheme,
} as const;
export const lightTheme = {
colors: {
...lightSemanticColors,
palette: baseColors,
},
...commonTheme,
} as const;
// Keep compatibility with existing code
export const theme = darkTheme;
// Export a union type that works for both themes
export type Theme = typeof darkTheme | typeof lightTheme;
+48
View File
@@ -0,0 +1,48 @@
import { StyleSheet } from "react-native-unistyles";
// import { UnistylesRuntime } from "react-native-unistyles";
import { lightTheme, darkTheme } from "./theme";
console.log("[Unistyles] Configuring...");
// // Configure Unistyles with adaptive themes
StyleSheet.configure({
themes: {
light: lightTheme,
dark: darkTheme,
},
breakpoints: {
xs: 0,
sm: 576,
md: 768,
lg: 992,
xl: 1200,
},
settings: {
initialTheme: "dark",
},
});
console.log("[Unistyles] Configuration complete!");
// Type augmentation for TypeScript
type AppThemes = {
light: typeof lightTheme;
dark: typeof darkTheme;
};
type AppBreakpoints = {
xs: number;
sm: number;
md: number;
lg: number;
xl: number;
};
declare module "react-native-unistyles" {
export interface UnistylesThemes extends AppThemes {}
export interface UnistylesBreakpoints extends AppBreakpoints {}
}
console.log(lightTheme.colors.background);
// UnistylesRuntime.setRootViewBackgroundColor(lightTheme.colors.background);