docs: update tmux commands formatting and add Android screenshot instructions

This commit is contained in:
Mohamed Boudra
2025-10-22 16:50:07 +02:00
parent 89641a18e7
commit de5aba4186
18 changed files with 1217 additions and 1983 deletions
+7 -2
View File
@@ -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`
Symlink
+1
View File
@@ -0,0 +1 @@
.claude/CLAUDE.md
+841 -1683
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
View File
@@ -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.
+1
View File
@@ -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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

+3
View File
@@ -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>
<KeyboardProvider>
<Stack screenOptions={{ headerShown: false }}> <Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" /> <Stack.Screen name="index" />
<Stack.Screen name="settings" /> <Stack.Screen name="settings" />
<Stack.Screen name="audio-test" /> <Stack.Screen name="audio-test" />
</Stack> </Stack>
</KeyboardProvider>
</SafeAreaProvider> </SafeAreaProvider>
); );
} }
+41 -13
View File
@@ -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,12 +975,9 @@ 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}
>
{/* Connection status header with buttons */}
<View style={{ paddingTop: insets.top + 16 }}> <View style={{ paddingTop: insets.top + 16 }}>
<View style={styles.headerRow}> <View style={styles.headerRow}>
<View style={styles.headerLeft}> <View style={styles.headerLeft}>
@@ -996,8 +1008,13 @@ export default function VoiceAssistantScreen() {
onSelectAgent={handleSelectAgent} onSelectAgent={handleSelectAgent}
onBackToOrchestrator={handleBackToOrchestrator} onBackToOrchestrator={handleBackToOrchestrator}
/> />
</View>
{/* Messages */} {/* Content Area with Keyboard Handling */}
<ReanimatedAnimated.View
style={[styles.contentArea, animatedKeyboardStyle]}
>
{/* Scrollable Messages Area */}
<ScrollView <ScrollView
ref={scrollViewRef} ref={scrollViewRef}
style={styles.scrollView} style={styles.scrollView}
@@ -1080,13 +1097,11 @@ export default function VoiceAssistantScreen() {
)} )}
</ScrollView> </ScrollView>
{/* Input area */} {/* Fixed Footer */}
<View <View
style={[ style={[
styles.inputArea, styles.inputArea,
{ { paddingBottom: Math.max(insets.bottom, 8) },
paddingBottom: Math.max(insets.bottom, 32),
},
]} ]}
> >
{/* Text input */} {/* Text input */}
@@ -1144,13 +1159,14 @@ export default function VoiceAssistantScreen() {
</Pressable> </Pressable>
</View> </View>
</View> </View>
</ReanimatedAnimated.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,20 +40,25 @@ 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",
(message) => {
if (message.type !== "list_conversations_response") return;
setConversations(message.payload.conversations); setConversations(message.payload.conversations);
setIsLoading(false); 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.type !== "delete_conversation_response") return;
console.log("[ConversationSelector] Delete response:", message.payload);
if (message.payload.success) { if (message.payload.success) {
// Refresh conversations list // Refresh conversations list
fetchConversations(); fetchConversations();
@@ -54,9 +68,13 @@ export function ConversationSelector({
handleNewConversation(); handleNewConversation();
} }
} else { } else {
Alert.alert('Error', `Failed to delete conversation: ${message.payload.error}`); 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,17 +265,21 @@ export function ConversationSelector({
</View> </View>
)} )}
{!isLoading && conversations.map((conversation) => ( {!isLoading &&
conversations.map((conversation) => (
<View <View
key={conversation.id} key={conversation.id}
style={[ style={[
styles.conversationItem, styles.conversationItem,
conversation.id === currentConversationId && styles.conversationItemActive conversation.id === currentConversationId &&
styles.conversationItemActive,
]} ]}
> >
<Pressable <Pressable
style={styles.conversationContent} style={styles.conversationContent}
onPress={() => handleSelectConversation(conversation.id)} onPress={() =>
handleSelectConversation(conversation.id)
}
> >
<Text style={styles.conversationTitle}> <Text style={styles.conversationTitle}>
{conversation.messageCount} messages {conversation.messageCount} messages
@@ -256,7 +289,9 @@ export function ConversationSelector({
</Text> </Text>
</Pressable> </Pressable>
<Pressable <Pressable
onPress={() => handleDeleteConversation(conversation.id)} onPress={() =>
handleDeleteConversation(conversation.id)
}
style={styles.deleteButton} style={styles.deleteButton}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
> >
@@ -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],
Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.