docs: update tmux commands formatting and add Android screenshot instructions
@@ -18,6 +18,11 @@ This is an npm workspace monorepo:
|
|||||||
|
|
||||||
We run the mobile app and server in the tmux session `voice-dev`:
|
We run the mobile app and server in the tmux session `voice-dev`:
|
||||||
|
|
||||||
tmux capture-pane -t voice-dev:mobile -p
|
`tmux capture-pane -t voice-dev:mobile -p`
|
||||||
|
`tmux capture-pane -t voice-dev:server -p`
|
||||||
|
|
||||||
tmux capture-pane -t voice-dev:server -p
|
Don't run them anywhere else. Check logs there.
|
||||||
|
|
||||||
|
## Andriod
|
||||||
|
|
||||||
|
Take screensots like this: `adb exec-out screencap -p > screenshot.png`
|
||||||
|
|||||||
2524
package-lock.json
generated
@@ -30,7 +30,7 @@
|
|||||||
},
|
},
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
"predictiveBackGestureEnabled": false,
|
"predictiveBackGestureEnabled": false,
|
||||||
"softwareKeyboardLayoutMode": "pan",
|
"softwareKeyboardLayoutMode": "resize",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"RECORD_AUDIO",
|
"RECORD_AUDIO",
|
||||||
"android.permission.RECORD_AUDIO",
|
"android.permission.RECORD_AUDIO",
|
||||||
|
|||||||
3
packages/app/nativewind-env.d.ts
vendored
@@ -1,3 +0,0 @@
|
|||||||
/// <reference types="react-native-css/types" />
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited and should be committed with your source code. It is generated by react-native-css. If you need to move or disable this file, please see the documentation.
|
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
"react-native-css": "^3.0.1",
|
"react-native-css": "^3.0.1",
|
||||||
"react-native-edge-to-edge": "^1.7.0",
|
"react-native-edge-to-edge": "^1.7.0",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
|
"react-native-keyboard-controller": "^1.19.2",
|
||||||
"react-native-markdown-display": "^7.0.2",
|
"react-native-markdown-display": "^7.0.2",
|
||||||
"react-native-nitro-modules": "^0.30.0",
|
"react-native-nitro-modules": "^0.30.0",
|
||||||
"react-native-permissions": "^5.4.2",
|
"react-native-permissions": "^5.4.2",
|
||||||
|
|||||||
BIN
packages/app/screenshot-test.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
@@ -1,14 +1,17 @@
|
|||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
import { KeyboardProvider } from 'react-native-keyboard-controller';
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<Stack screenOptions={{ headerShown: false }}>
|
<KeyboardProvider>
|
||||||
<Stack.Screen name="index" />
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
<Stack.Screen name="settings" />
|
<Stack.Screen name="index" />
|
||||||
<Stack.Screen name="audio-test" />
|
<Stack.Screen name="settings" />
|
||||||
</Stack>
|
<Stack.Screen name="audio-test" />
|
||||||
|
</Stack>
|
||||||
|
</KeyboardProvider>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import {
|
|||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Animated,
|
Animated,
|
||||||
|
Keyboard,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||||
|
import ReanimatedAnimated, { useAnimatedStyle } from "react-native-reanimated";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
@@ -131,9 +134,21 @@ export default function VoiceAssistantScreen() {
|
|||||||
const [isRealtimeMode, setIsRealtimeMode] = useState(false);
|
const [isRealtimeMode, setIsRealtimeMode] = useState(false);
|
||||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
const audioRecorder = useAudioRecorder();
|
const audioRecorder = useAudioRecorder();
|
||||||
const audioPlayer = useAudioPlayer();
|
const audioPlayer = useAudioPlayer();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
// Keyboard animation
|
||||||
|
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||||
|
const bottomInset = insets.bottom;
|
||||||
|
const animatedKeyboardStyle = useAnimatedStyle(() => {
|
||||||
|
'worklet';
|
||||||
|
const absoluteHeight = Math.abs(keyboardHeight.value);
|
||||||
|
const padding = Math.max(0, absoluteHeight - bottomInset);
|
||||||
|
return {
|
||||||
|
paddingBottom: padding,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Realtime audio with Speechmatics (echo cancellation)
|
// Realtime audio with Speechmatics (echo cancellation)
|
||||||
const realtimeAudio = useSpeechmaticsAudio({
|
const realtimeAudio = useSpeechmaticsAudio({
|
||||||
@@ -960,197 +975,198 @@ export default function VoiceAssistantScreen() {
|
|||||||
|
|
||||||
// Render orchestrator view (main chat)
|
// Render orchestrator view (main chat)
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<View style={styles.container}>
|
||||||
behavior="padding"
|
{/* Fixed Header */}
|
||||||
style={styles.container}
|
<View style={styles.header}>
|
||||||
keyboardVerticalOffset={Platform.OS === "ios" ? insets.top : 0}
|
<View style={{ paddingTop: insets.top + 16 }}>
|
||||||
>
|
<View style={styles.headerRow}>
|
||||||
{/* Connection status header with buttons */}
|
<View style={styles.headerLeft}>
|
||||||
<View style={{ paddingTop: insets.top + 16 }}>
|
<ConnectionStatus isConnected={ws.isConnected} />
|
||||||
<View style={styles.headerRow}>
|
</View>
|
||||||
<View style={styles.headerLeft}>
|
<View style={styles.headerRight}>
|
||||||
<ConnectionStatus isConnected={ws.isConnected} />
|
<ConversationSelector
|
||||||
|
currentConversationId={conversationId}
|
||||||
|
onSelectConversation={handleSelectConversation}
|
||||||
|
websocket={ws}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push("/settings")}
|
||||||
|
style={styles.settingsButton}
|
||||||
|
>
|
||||||
|
<Settings size={20} color="white" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.headerRight}>
|
</View>
|
||||||
<ConversationSelector
|
|
||||||
currentConversationId={conversationId}
|
{/* Active processes bar */}
|
||||||
onSelectConversation={handleSelectConversation}
|
<ActiveProcesses
|
||||||
websocket={ws}
|
agents={Array.from(agents.values())}
|
||||||
|
commands={Array.from(commands.values())}
|
||||||
|
activeProcessId={activeAgentId}
|
||||||
|
activeProcessType={activeAgentId ? "agent" : null}
|
||||||
|
onSelectAgent={handleSelectAgent}
|
||||||
|
onBackToOrchestrator={handleBackToOrchestrator}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content Area with Keyboard Handling */}
|
||||||
|
<ReanimatedAnimated.View
|
||||||
|
style={[styles.contentArea, animatedKeyboardStyle]}
|
||||||
|
>
|
||||||
|
{/* Scrollable Messages Area */}
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollViewRef}
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
keyboardDismissMode="on-drag"
|
||||||
|
>
|
||||||
|
{messages.map((msg) => {
|
||||||
|
if (msg.type === "user") {
|
||||||
|
return (
|
||||||
|
<UserMessage
|
||||||
|
key={msg.id}
|
||||||
|
message={msg.message}
|
||||||
|
timestamp={msg.timestamp}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === "assistant") {
|
||||||
|
return (
|
||||||
|
<AssistantMessage
|
||||||
|
key={msg.id}
|
||||||
|
message={msg.message}
|
||||||
|
timestamp={msg.timestamp}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === "activity") {
|
||||||
|
return (
|
||||||
|
<ActivityLog
|
||||||
|
key={msg.id}
|
||||||
|
type={msg.activityType}
|
||||||
|
message={msg.message}
|
||||||
|
timestamp={msg.timestamp}
|
||||||
|
metadata={msg.metadata}
|
||||||
|
onArtifactClick={handleArtifactClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === "artifact") {
|
||||||
|
return (
|
||||||
|
<ActivityLog
|
||||||
|
key={msg.id}
|
||||||
|
type="artifact"
|
||||||
|
message=""
|
||||||
|
timestamp={msg.timestamp}
|
||||||
|
artifactId={msg.artifactId}
|
||||||
|
artifactType={msg.artifactType}
|
||||||
|
title={msg.title}
|
||||||
|
onArtifactClick={handleArtifactClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === "tool_call") {
|
||||||
|
return (
|
||||||
|
<ToolCall
|
||||||
|
key={msg.id}
|
||||||
|
toolName={msg.toolName}
|
||||||
|
args={msg.args}
|
||||||
|
result={msg.result}
|
||||||
|
error={msg.error}
|
||||||
|
status={msg.status}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Streaming assistant message */}
|
||||||
|
{currentAssistantMessage && (
|
||||||
|
<AssistantMessage
|
||||||
|
message={currentAssistantMessage}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
isStreaming={true}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Fixed Footer */}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.inputArea,
|
||||||
|
{ paddingBottom: Math.max(insets.bottom, 8) },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* Text input */}
|
||||||
|
<TextInput
|
||||||
|
value={userInput}
|
||||||
|
onChangeText={setUserInput}
|
||||||
|
placeholder="Say something..."
|
||||||
|
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||||
|
style={styles.textInput}
|
||||||
|
multiline
|
||||||
|
editable={!isRecording && ws.isConnected}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<View style={styles.buttonRow}>
|
||||||
|
{/* Realtime mode button */}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => router.push("/settings")}
|
onPress={handleRealtimeToggle}
|
||||||
style={styles.settingsButton}
|
disabled={!ws.isConnected}
|
||||||
|
style={[
|
||||||
|
styles.realtimeButton,
|
||||||
|
!ws.isConnected && styles.buttonDisabled,
|
||||||
|
isRealtimeMode && styles.realtimeButtonActive,
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Settings size={20} color="white" />
|
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
|
||||||
|
<AudioLines size={20} color="white" />
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Main action button */}
|
||||||
|
<Pressable
|
||||||
|
onPress={handleButtonClick}
|
||||||
|
disabled={!ws.isConnected}
|
||||||
|
style={[
|
||||||
|
styles.mainButton,
|
||||||
|
!ws.isConnected && styles.buttonDisabled,
|
||||||
|
isRecording && styles.mainButtonRecording,
|
||||||
|
isInProgress && styles.mainButtonInProgress,
|
||||||
|
userInput.trim() &&
|
||||||
|
!isRecording &&
|
||||||
|
!isInProgress &&
|
||||||
|
styles.mainButtonWithText,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{isInProgress ? (
|
||||||
|
<Square size={18} color="white" fill="white" />
|
||||||
|
) : isRecording ? (
|
||||||
|
<Square size={14} color="white" fill="white" />
|
||||||
|
) : userInput.trim() ? (
|
||||||
|
<ArrowUp size={20} color="white" />
|
||||||
|
) : (
|
||||||
|
<Mic size={20} color="white" />
|
||||||
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</ReanimatedAnimated.View>
|
||||||
|
|
||||||
{/* Active processes bar */}
|
|
||||||
<ActiveProcesses
|
|
||||||
agents={Array.from(agents.values())}
|
|
||||||
commands={Array.from(commands.values())}
|
|
||||||
activeProcessId={activeAgentId}
|
|
||||||
activeProcessType={activeAgentId ? "agent" : null}
|
|
||||||
onSelectAgent={handleSelectAgent}
|
|
||||||
onBackToOrchestrator={handleBackToOrchestrator}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Messages */}
|
|
||||||
<ScrollView
|
|
||||||
ref={scrollViewRef}
|
|
||||||
style={styles.scrollView}
|
|
||||||
contentContainerStyle={styles.scrollContent}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
keyboardDismissMode="on-drag"
|
|
||||||
>
|
|
||||||
{messages.map((msg) => {
|
|
||||||
if (msg.type === "user") {
|
|
||||||
return (
|
|
||||||
<UserMessage
|
|
||||||
key={msg.id}
|
|
||||||
message={msg.message}
|
|
||||||
timestamp={msg.timestamp}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.type === "assistant") {
|
|
||||||
return (
|
|
||||||
<AssistantMessage
|
|
||||||
key={msg.id}
|
|
||||||
message={msg.message}
|
|
||||||
timestamp={msg.timestamp}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.type === "activity") {
|
|
||||||
return (
|
|
||||||
<ActivityLog
|
|
||||||
key={msg.id}
|
|
||||||
type={msg.activityType}
|
|
||||||
message={msg.message}
|
|
||||||
timestamp={msg.timestamp}
|
|
||||||
metadata={msg.metadata}
|
|
||||||
onArtifactClick={handleArtifactClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.type === "artifact") {
|
|
||||||
return (
|
|
||||||
<ActivityLog
|
|
||||||
key={msg.id}
|
|
||||||
type="artifact"
|
|
||||||
message=""
|
|
||||||
timestamp={msg.timestamp}
|
|
||||||
artifactId={msg.artifactId}
|
|
||||||
artifactType={msg.artifactType}
|
|
||||||
title={msg.title}
|
|
||||||
onArtifactClick={handleArtifactClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.type === "tool_call") {
|
|
||||||
return (
|
|
||||||
<ToolCall
|
|
||||||
key={msg.id}
|
|
||||||
toolName={msg.toolName}
|
|
||||||
args={msg.args}
|
|
||||||
result={msg.result}
|
|
||||||
error={msg.error}
|
|
||||||
status={msg.status}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Streaming assistant message */}
|
|
||||||
{currentAssistantMessage && (
|
|
||||||
<AssistantMessage
|
|
||||||
message={currentAssistantMessage}
|
|
||||||
timestamp={Date.now()}
|
|
||||||
isStreaming={true}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
{/* Input area */}
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.inputArea,
|
|
||||||
{
|
|
||||||
paddingBottom: Math.max(insets.bottom, 32),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{/* Text input */}
|
|
||||||
<TextInput
|
|
||||||
value={userInput}
|
|
||||||
onChangeText={setUserInput}
|
|
||||||
placeholder="Say something..."
|
|
||||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
|
||||||
style={styles.textInput}
|
|
||||||
multiline
|
|
||||||
editable={!isRecording && ws.isConnected}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Buttons */}
|
|
||||||
<View style={styles.buttonRow}>
|
|
||||||
{/* Realtime mode button */}
|
|
||||||
<Pressable
|
|
||||||
onPress={handleRealtimeToggle}
|
|
||||||
disabled={!ws.isConnected}
|
|
||||||
style={[
|
|
||||||
styles.realtimeButton,
|
|
||||||
!ws.isConnected && styles.buttonDisabled,
|
|
||||||
isRealtimeMode && styles.realtimeButtonActive,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
|
|
||||||
<AudioLines size={20} color="white" />
|
|
||||||
</Animated.View>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{/* Main action button */}
|
|
||||||
<Pressable
|
|
||||||
onPress={handleButtonClick}
|
|
||||||
disabled={!ws.isConnected}
|
|
||||||
style={[
|
|
||||||
styles.mainButton,
|
|
||||||
!ws.isConnected && styles.buttonDisabled,
|
|
||||||
isRecording && styles.mainButtonRecording,
|
|
||||||
isInProgress && styles.mainButtonInProgress,
|
|
||||||
userInput.trim() &&
|
|
||||||
!isRecording &&
|
|
||||||
!isInProgress &&
|
|
||||||
styles.mainButtonWithText,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{isInProgress ? (
|
|
||||||
<Square size={18} color="white" fill="white" />
|
|
||||||
) : isRecording ? (
|
|
||||||
<Square size={14} color="white" fill="white" />
|
|
||||||
) : userInput.trim() ? (
|
|
||||||
<ArrowUp size={20} color="white" />
|
|
||||||
) : (
|
|
||||||
<Mic size={20} color="white" />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Artifact drawer */}
|
{/* Artifact drawer */}
|
||||||
<ArtifactDrawer
|
<ArtifactDrawer
|
||||||
artifact={currentArtifact}
|
artifact={currentArtifact}
|
||||||
onClose={handleCloseArtifact}
|
onClose={handleCloseArtifact}
|
||||||
/>
|
/>
|
||||||
</KeyboardAvoidingView>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1168,12 +1184,21 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
agentNotFoundText: {
|
agentNotFoundText: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
},
|
},
|
||||||
|
header: {
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
},
|
||||||
|
contentArea: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
headerRow: {
|
headerRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
paddingHorizontal: theme.spacing[6],
|
paddingHorizontal: theme.spacing[6],
|
||||||
paddingBottom: theme.spacing[4],
|
paddingBottom: theme.spacing[4],
|
||||||
|
borderBottomWidth: theme.borderWidth[1],
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
},
|
},
|
||||||
headerLeft: {
|
headerLeft: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -1190,9 +1215,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
scrollView: {
|
scrollView: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
},
|
},
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
paddingBottom: theme.spacing[4],
|
paddingBottom: theme.spacing[4],
|
||||||
|
flexGrow: 1,
|
||||||
},
|
},
|
||||||
inputArea: {
|
inputArea: {
|
||||||
paddingTop: theme.spacing[4],
|
paddingTop: theme.spacing[4],
|
||||||
@@ -1215,6 +1242,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
gap: theme.spacing[3],
|
gap: theme.spacing[3],
|
||||||
|
marginBottom: theme.spacing[2],
|
||||||
},
|
},
|
||||||
realtimeButton: {
|
realtimeButton: {
|
||||||
width: 48,
|
width: 48,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { View, Text } from 'react-native';
|
import { View, Text } from "react-native";
|
||||||
import { StyleSheet } from 'react-native-unistyles';
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
|
||||||
interface ConnectionStatusProps {
|
interface ConnectionStatusProps {
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
@@ -7,14 +7,11 @@ interface ConnectionStatusProps {
|
|||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
paddingHorizontal: theme.spacing[4],
|
// No padding or border - parent handles layout
|
||||||
paddingVertical: theme.spacing[3],
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: theme.colors.border,
|
|
||||||
},
|
},
|
||||||
row: {
|
row: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
dot: {
|
dot: {
|
||||||
width: 8,
|
width: 8,
|
||||||
@@ -43,9 +40,19 @@ export function ConnectionStatus({ isConnected }: ConnectionStatusProps) {
|
|||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<View style={[styles.dot, isConnected ? styles.dotConnected : styles.dotDisconnected]} />
|
<View
|
||||||
<Text style={[styles.text, isConnected ? styles.textConnected : styles.textDisconnected]}>
|
style={[
|
||||||
{isConnected ? 'Connected' : 'Disconnected'}
|
styles.dot,
|
||||||
|
isConnected ? styles.dotConnected : styles.dotDisconnected,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.text,
|
||||||
|
isConnected ? styles.textConnected : styles.textDisconnected,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{isConnected ? "Connected" : "Disconnected"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
import { Modal, View, Text, Pressable, ScrollView, Alert, KeyboardAvoidingView, Platform } from 'react-native';
|
import {
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
Modal,
|
||||||
import { MessageSquare, X, Plus, Trash2 } from 'lucide-react-native';
|
View,
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
Text,
|
||||||
import { StyleSheet } from 'react-native-unistyles';
|
Pressable,
|
||||||
import type { UseWebSocketReturn } from '../hooks/use-websocket';
|
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';
|
const STORAGE_KEY = "@voice-dev:conversation-id";
|
||||||
|
|
||||||
interface Conversation {
|
interface Conversation {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -31,32 +40,41 @@ export function ConversationSelector({
|
|||||||
|
|
||||||
// Listen for conversation list responses
|
// Listen for conversation list responses
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = websocket.on('list_conversations_response', (message) => {
|
const unsubscribe = websocket.on(
|
||||||
if (message.type !== 'list_conversations_response') return;
|
"list_conversations_response",
|
||||||
setConversations(message.payload.conversations);
|
(message) => {
|
||||||
setIsLoading(false);
|
if (message.type !== "list_conversations_response") return;
|
||||||
});
|
setConversations(message.payload.conversations);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [websocket]);
|
}, [websocket]);
|
||||||
|
|
||||||
// Listen for delete conversation responses
|
// Listen for delete conversation responses
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = websocket.on('delete_conversation_response', (message) => {
|
const unsubscribe = websocket.on(
|
||||||
if (message.type !== 'delete_conversation_response') return;
|
"delete_conversation_response",
|
||||||
console.log('[ConversationSelector] Delete response:', message.payload);
|
(message) => {
|
||||||
if (message.payload.success) {
|
if (message.type !== "delete_conversation_response") return;
|
||||||
// Refresh conversations list
|
console.log("[ConversationSelector] Delete response:", message.payload);
|
||||||
fetchConversations();
|
if (message.payload.success) {
|
||||||
|
// Refresh conversations list
|
||||||
|
fetchConversations();
|
||||||
|
|
||||||
// If we deleted the current conversation, start a new one
|
// If we deleted the current conversation, start a new one
|
||||||
if (message.payload.conversationId === currentConversationId) {
|
if (message.payload.conversationId === currentConversationId) {
|
||||||
handleNewConversation();
|
handleNewConversation();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Alert.alert(
|
||||||
|
"Error",
|
||||||
|
`Failed to delete conversation: ${message.payload.error}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Alert.alert('Error', `Failed to delete conversation: ${message.payload.error}`);
|
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [websocket, currentConversationId]);
|
}, [websocket, currentConversationId]);
|
||||||
@@ -70,30 +88,35 @@ export function ConversationSelector({
|
|||||||
|
|
||||||
function fetchConversations() {
|
function fetchConversations() {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
console.log('[ConversationSelector] Requesting conversations via WebSocket');
|
console.log(
|
||||||
|
"[ConversationSelector] Requesting conversations via WebSocket"
|
||||||
|
);
|
||||||
websocket.send({
|
websocket.send({
|
||||||
type: 'session',
|
type: "session",
|
||||||
message: {
|
message: {
|
||||||
type: 'list_conversations_request',
|
type: "list_conversations_request",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDeleteConversation(id: string) {
|
function handleDeleteConversation(id: string) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Delete Conversation',
|
"Delete Conversation",
|
||||||
'Are you sure you want to delete this conversation?',
|
"Are you sure you want to delete this conversation?",
|
||||||
[
|
[
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
{ text: "Cancel", style: "cancel" },
|
||||||
{
|
{
|
||||||
text: 'Delete',
|
text: "Delete",
|
||||||
style: 'destructive',
|
style: "destructive",
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
console.log('[ConversationSelector] Deleting conversation via WebSocket:', id);
|
console.log(
|
||||||
|
"[ConversationSelector] Deleting conversation via WebSocket:",
|
||||||
|
id
|
||||||
|
);
|
||||||
websocket.send({
|
websocket.send({
|
||||||
type: 'session',
|
type: "session",
|
||||||
message: {
|
message: {
|
||||||
type: 'delete_conversation_request',
|
type: "delete_conversation_request",
|
||||||
conversationId: id,
|
conversationId: id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -105,21 +128,23 @@ export function ConversationSelector({
|
|||||||
|
|
||||||
function handleClearAll() {
|
function handleClearAll() {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Clear All Conversations',
|
"Clear All Conversations",
|
||||||
'Are you sure you want to delete all conversations?',
|
"Are you sure you want to delete all conversations?",
|
||||||
[
|
[
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
{ text: "Cancel", style: "cancel" },
|
||||||
{
|
{
|
||||||
text: 'Clear All',
|
text: "Clear All",
|
||||||
style: 'destructive',
|
style: "destructive",
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
console.log('[ConversationSelector] Clearing all conversations via WebSocket');
|
console.log(
|
||||||
|
"[ConversationSelector] Clearing all conversations via WebSocket"
|
||||||
|
);
|
||||||
// Delete all conversations
|
// Delete all conversations
|
||||||
conversations.forEach((conv) => {
|
conversations.forEach((conv) => {
|
||||||
websocket.send({
|
websocket.send({
|
||||||
type: 'session',
|
type: "session",
|
||||||
message: {
|
message: {
|
||||||
type: 'delete_conversation_request',
|
type: "delete_conversation_request",
|
||||||
conversationId: conv.id,
|
conversationId: conv.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -144,7 +169,10 @@ export function ConversationSelector({
|
|||||||
onSelectConversation(id);
|
onSelectConversation(id);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ConversationSelector] Failed to save conversation ID:', error);
|
console.error(
|
||||||
|
"[ConversationSelector] Failed to save conversation ID:",
|
||||||
|
error
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +183,10 @@ export function ConversationSelector({
|
|||||||
onSelectConversation(null);
|
onSelectConversation(null);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ConversationSelector] Failed to clear conversation ID:', error);
|
console.error(
|
||||||
|
"[ConversationSelector] Failed to clear conversation ID:",
|
||||||
|
error
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +198,7 @@ export function ConversationSelector({
|
|||||||
const diffHours = Math.floor(diffMs / 3600000);
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
const diffDays = Math.floor(diffMs / 86400000);
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
if (diffMins < 1) return 'Just now';
|
if (diffMins < 1) return "Just now";
|
||||||
if (diffMins < 60) return `${diffMins}m ago`;
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
if (diffHours < 24) return `${diffHours}h ago`;
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
if (diffDays < 7) return `${diffDays}d ago`;
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
@@ -176,10 +207,7 @@ export function ConversationSelector({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<Pressable
|
<Pressable onPress={() => setIsOpen(true)} style={styles.triggerButton}>
|
||||||
onPress={() => setIsOpen(true)}
|
|
||||||
style={styles.triggerButton}
|
|
||||||
>
|
|
||||||
<MessageSquare size={20} color="white" />
|
<MessageSquare size={20} color="white" />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
@@ -199,7 +227,8 @@ export function ConversationSelector({
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.headerTitle}>
|
<Text style={styles.headerTitle}>
|
||||||
Conversations {conversations.length > 0 && `(${conversations.length})`}
|
Conversations{" "}
|
||||||
|
{conversations.length > 0 && `(${conversations.length})`}
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable onPress={() => setIsOpen(false)}>
|
<Pressable onPress={() => setIsOpen(false)}>
|
||||||
<X size={24} color="white" />
|
<X size={24} color="white" />
|
||||||
@@ -236,34 +265,40 @@ export function ConversationSelector({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && conversations.map((conversation) => (
|
{!isLoading &&
|
||||||
<View
|
conversations.map((conversation) => (
|
||||||
key={conversation.id}
|
<View
|
||||||
style={[
|
key={conversation.id}
|
||||||
styles.conversationItem,
|
style={[
|
||||||
conversation.id === currentConversationId && styles.conversationItemActive
|
styles.conversationItem,
|
||||||
]}
|
conversation.id === currentConversationId &&
|
||||||
>
|
styles.conversationItemActive,
|
||||||
<Pressable
|
]}
|
||||||
style={styles.conversationContent}
|
|
||||||
onPress={() => handleSelectConversation(conversation.id)}
|
|
||||||
>
|
>
|
||||||
<Text style={styles.conversationTitle}>
|
<Pressable
|
||||||
{conversation.messageCount} messages
|
style={styles.conversationContent}
|
||||||
</Text>
|
onPress={() =>
|
||||||
<Text style={styles.conversationDate}>
|
handleSelectConversation(conversation.id)
|
||||||
{formatDate(conversation.lastUpdated)}
|
}
|
||||||
</Text>
|
>
|
||||||
</Pressable>
|
<Text style={styles.conversationTitle}>
|
||||||
<Pressable
|
{conversation.messageCount} messages
|
||||||
onPress={() => handleDeleteConversation(conversation.id)}
|
</Text>
|
||||||
style={styles.deleteButton}
|
<Text style={styles.conversationDate}>
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
{formatDate(conversation.lastUpdated)}
|
||||||
>
|
</Text>
|
||||||
<Trash2 size={20} color="#ef4444" />
|
</Pressable>
|
||||||
</Pressable>
|
<Pressable
|
||||||
</View>
|
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 && (
|
{!isLoading && conversations.length > 0 && (
|
||||||
<View style={styles.clearAllContainer}>
|
<View style={styles.clearAllContainer}>
|
||||||
@@ -288,13 +323,12 @@ export function ConversationSelector({
|
|||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
triggerButton: {
|
triggerButton: {
|
||||||
backgroundColor: theme.colors.muted,
|
backgroundColor: theme.colors.muted,
|
||||||
paddingHorizontal: theme.spacing[4],
|
padding: theme.spacing[3],
|
||||||
paddingVertical: theme.spacing[2],
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
},
|
},
|
||||||
modalOverlay: {
|
modalOverlay: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
backgroundColor: "rgba(0,0,0,0.5)",
|
||||||
},
|
},
|
||||||
modalBackdrop: {
|
modalBackdrop: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -303,15 +337,15 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: theme.colors.card,
|
backgroundColor: theme.colors.card,
|
||||||
borderTopLeftRadius: theme.spacing[6],
|
borderTopLeftRadius: theme.spacing[6],
|
||||||
borderTopRightRadius: theme.spacing[6],
|
borderTopRightRadius: theme.spacing[6],
|
||||||
height: '80%',
|
height: "80%",
|
||||||
},
|
},
|
||||||
modalInner: {
|
modalInner: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'space-between',
|
justifyContent: "space-between",
|
||||||
padding: theme.spacing[6],
|
padding: theme.spacing[6],
|
||||||
borderBottomWidth: theme.borderWidth[1],
|
borderBottomWidth: theme.borderWidth[1],
|
||||||
borderBottomColor: theme.colors.border,
|
borderBottomColor: theme.colors.border,
|
||||||
@@ -327,9 +361,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderBottomColor: theme.colors.border,
|
borderBottomColor: theme.colors.border,
|
||||||
},
|
},
|
||||||
newButton: {
|
newButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
backgroundColor: theme.colors.muted,
|
backgroundColor: theme.colors.muted,
|
||||||
paddingVertical: theme.spacing[3],
|
paddingVertical: theme.spacing[3],
|
||||||
@@ -347,26 +381,26 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
loadingContainer: {
|
loadingContainer: {
|
||||||
padding: theme.spacing[8],
|
padding: theme.spacing[8],
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
loadingText: {
|
loadingText: {
|
||||||
color: theme.colors.mutedForeground,
|
color: theme.colors.mutedForeground,
|
||||||
},
|
},
|
||||||
emptyContainer: {
|
emptyContainer: {
|
||||||
padding: theme.spacing[8],
|
padding: theme.spacing[8],
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
emptyText: {
|
emptyText: {
|
||||||
color: theme.colors.mutedForeground,
|
color: theme.colors.mutedForeground,
|
||||||
},
|
},
|
||||||
conversationItem: {
|
conversationItem: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'space-between',
|
justifyContent: "space-between",
|
||||||
padding: theme.spacing[4],
|
padding: theme.spacing[4],
|
||||||
borderBottomWidth: theme.borderWidth[1],
|
borderBottomWidth: theme.borderWidth[1],
|
||||||
borderBottomColor: theme.colors.border,
|
borderBottomColor: theme.colors.border,
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: "transparent",
|
||||||
},
|
},
|
||||||
conversationItemActive: {
|
conversationItemActive: {
|
||||||
backgroundColor: theme.colors.muted,
|
backgroundColor: theme.colors.muted,
|
||||||
@@ -391,11 +425,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
padding: theme.spacing[4],
|
padding: theme.spacing[4],
|
||||||
},
|
},
|
||||||
clearAllButton: {
|
clearAllButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
backgroundColor: 'rgba(127, 29, 29, 0.2)',
|
backgroundColor: "rgba(127, 29, 29, 0.2)",
|
||||||
paddingVertical: theme.spacing[3],
|
paddingVertical: theme.spacing[3],
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[1],
|
||||||
|
|||||||
BIN
screenshot-keyboard.png
Normal file
|
After Width: | Height: | Size: 178 KiB |
BIN
screenshot-now.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
screenshot-test.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
screenshot-test2.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
screenshot-with-keyboard.png
Normal file
|
After Width: | Height: | Size: 182 KiB |
BIN
screenshot.png
Normal file
|
After Width: | Height: | Size: 105 KiB |