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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user