Matthias Nott
2026-03-02 a0f39302919fbacf7a0d407f01b1a50413ea6f70
feat: on-device speech recognition, navigation screen, session picker

- Replace audio recording + server-side Whisper with expo-speech-recognition
- Add navigate screen for iTerm2 remote control
- Add session picker modal for switching Claude sessions
- Restyle command bar with colored button backgrounds
- Add speech recognition and microphone permissions
2 files added
13 files modified
changed files
app.json patch | view | blame | history
app/chat.tsx patch | view | blame | history
app/navigate.tsx patch | view | blame | history
app/settings.tsx patch | view | blame | history
components/SessionPicker.tsx patch | view | blame | history
components/chat/CommandBar.tsx patch | view | blame | history
components/chat/InputBar.tsx patch | view | blame | history
components/chat/MessageBubble.tsx patch | view | blame | history
components/chat/VoiceButton.tsx patch | view | blame | history
contexts/ChatContext.tsx patch | view | blame | history
contexts/ConnectionContext.tsx patch | view | blame | history
package-lock.json patch | view | blame | history
package.json patch | view | blame | history
services/audio.ts patch | view | blame | history
types/index.ts patch | view | blame | history
app.json
....@@ -18,7 +18,8 @@
1818 "bundleIdentifier": "org.mnsoft.pailot",
1919 "appleTeamId": "7KU642K5ZL",
2020 "infoPlist": {
21
- "NSMicrophoneUsageDescription": "PAILot needs microphone access to record voice messages.",
21
+ "NSMicrophoneUsageDescription": "PAILot needs microphone access for voice input.",
22
+ "NSSpeechRecognitionUsageDescription": "PAILot uses speech recognition to convert your voice to text.",
2223 "UIBackgroundModes": [
2324 "audio"
2425 ]
....@@ -43,9 +44,16 @@
4344 "plugins": [
4445 "expo-router",
4546 [
46
- "expo-av",
47
+ "expo-audio",
4748 {
48
- "microphonePermission": "PAILot needs microphone access to record voice messages."
49
+ "microphonePermission": "PAILot needs microphone access for voice input."
50
+ }
51
+ ],
52
+ [
53
+ "expo-speech-recognition",
54
+ {
55
+ "microphonePermission": "PAILot needs microphone access for voice input.",
56
+ "speechRecognitionPermission": "PAILot uses speech recognition to convert your voice to text."
4957 }
5058 ],
5159 "expo-secure-store"
app/chat.tsx
....@@ -1,4 +1,4 @@
1
-import React, { useCallback } from "react";
1
+import React, { useCallback, useState } from "react";
22 import { Pressable, Text, View } from "react-native";
33 import { SafeAreaView } from "react-native-safe-area-context";
44 import { router } from "expo-router";
....@@ -6,69 +6,129 @@
66 import { useConnection } from "../contexts/ConnectionContext";
77 import { MessageList } from "../components/chat/MessageList";
88 import { InputBar } from "../components/chat/InputBar";
9
-import { CommandBar } from "../components/chat/CommandBar";
9
+import { CommandBar, TextModeCommandBar } from "../components/chat/CommandBar";
1010 import { StatusDot } from "../components/ui/StatusDot";
11
+import { SessionPicker } from "../components/SessionPicker";
12
+import { playAudio } from "../services/audio";
1113
1214 export default function ChatScreen() {
13
- const { messages, sendTextMessage, sendVoiceMessage, clearMessages } =
15
+ const { messages, sendTextMessage, clearMessages, requestScreenshot } =
1416 useChat();
1517 const { status } = useConnection();
18
+ const [isTextMode, setIsTextMode] = useState(false);
19
+ const [showSessions, setShowSessions] = useState(false);
1620
17
- const handleCommand = useCallback(
18
- (command: string) => {
19
- if (command === "/clear") {
20
- clearMessages();
21
+ const handleSessions = useCallback(() => {
22
+ setShowSessions(true);
23
+ }, []);
24
+
25
+ const handleScreenshot = useCallback(() => {
26
+ requestScreenshot();
27
+ router.push("/navigate");
28
+ }, [requestScreenshot]);
29
+
30
+ const handleHelp = useCallback(() => {
31
+ sendTextMessage("/h");
32
+ }, [sendTextMessage]);
33
+
34
+ const handleNavigate = useCallback(() => {
35
+ router.push("/navigate");
36
+ }, []);
37
+
38
+ const handleClear = useCallback(() => {
39
+ clearMessages();
40
+ }, [clearMessages]);
41
+
42
+ const handleReplay = useCallback(() => {
43
+ for (let i = messages.length - 1; i >= 0; i--) {
44
+ const msg = messages[i];
45
+ if (msg.role === "assistant") {
46
+ if (msg.audioUri) {
47
+ playAudio(msg.audioUri).catch(() => {});
48
+ }
2149 return;
2250 }
23
- sendTextMessage(command);
24
- },
25
- [sendTextMessage, clearMessages]
26
- );
27
-
28
- const handleSendVoice = useCallback(
29
- (audioUri: string, durationMs: number) => {
30
- sendVoiceMessage(audioUri, durationMs);
31
- },
32
- [sendVoiceMessage]
33
- );
51
+ }
52
+ }, [messages]);
3453
3554 return (
36
- <SafeAreaView className="flex-1 bg-pai-bg" edges={["top", "bottom"]}>
55
+ <SafeAreaView style={{ flex: 1, backgroundColor: "#0A0A0F" }} edges={["top", "bottom"]}>
3756 {/* Header */}
38
- <View className="flex-row items-center justify-between px-4 py-3 border-b border-pai-border">
39
- <Text className="text-pai-text text-xl font-bold tracking-tight">
40
- PAILot
41
- </Text>
42
- <View className="flex-row items-center gap-3">
43
- <StatusDot status={status} size={10} />
44
- <Text className="text-pai-text-secondary text-xs">
45
- {status === "connected"
46
- ? "Connected"
47
- : status === "connecting"
48
- ? "Connecting..."
49
- : "Offline"}
50
- </Text>
51
- <Pressable
52
- onPress={() => router.push("/settings")}
53
- className="w-9 h-9 items-center justify-center rounded-full bg-pai-bg-tertiary"
54
- hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }}
57
+ <View
58
+ style={{
59
+ flexDirection: "row",
60
+ alignItems: "center",
61
+ justifyContent: "space-between",
62
+ paddingHorizontal: 16,
63
+ paddingVertical: 12,
64
+ borderBottomWidth: 1,
65
+ borderBottomColor: "#2E2E45",
66
+ }}
67
+ >
68
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
69
+ <Text
70
+ style={{
71
+ color: "#E8E8F0",
72
+ fontSize: 22,
73
+ fontWeight: "800",
74
+ letterSpacing: -0.5,
75
+ }}
5576 >
56
- <Text className="text-base">⚙️</Text>
57
- </Pressable>
77
+ PAILot
78
+ </Text>
79
+ <StatusDot status={status} size={8} />
5880 </View>
81
+
82
+ <Pressable
83
+ onPress={() => router.push("/settings")}
84
+ hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
85
+ style={{
86
+ width: 36,
87
+ height: 36,
88
+ alignItems: "center",
89
+ justifyContent: "center",
90
+ borderRadius: 18,
91
+ backgroundColor: "#1E1E2E",
92
+ }}
93
+ >
94
+ <Text style={{ fontSize: 15 }}>⚙️</Text>
95
+ </Pressable>
5996 </View>
6097
6198 {/* Message list */}
62
- <View className="flex-1">
99
+ <View style={{ flex: 1 }}>
63100 {messages.length === 0 ? (
64
- <View className="flex-1 items-center justify-center gap-3">
65
- <Text className="text-5xl">🛩</Text>
66
- <Text className="text-pai-text text-xl font-semibold">
67
- PAILot
68
- </Text>
69
- <Text className="text-pai-text-muted text-sm text-center px-8">
70
- Voice-first AI communicator.{"\n"}Hold the mic button to talk.
71
- </Text>
101
+ <View style={{ flex: 1, alignItems: "center", justifyContent: "center", gap: 16 }}>
102
+ <View
103
+ style={{
104
+ width: 80,
105
+ height: 80,
106
+ borderRadius: 40,
107
+ backgroundColor: "#1E1E2E",
108
+ alignItems: "center",
109
+ justifyContent: "center",
110
+ borderWidth: 1,
111
+ borderColor: "#2E2E45",
112
+ }}
113
+ >
114
+ <Text style={{ fontSize: 36 }}>🛩</Text>
115
+ </View>
116
+ <View style={{ alignItems: "center", gap: 6 }}>
117
+ <Text style={{ color: "#E8E8F0", fontSize: 20, fontWeight: "700" }}>
118
+ PAILot
119
+ </Text>
120
+ <Text
121
+ style={{
122
+ color: "#5A5A78",
123
+ fontSize: 14,
124
+ textAlign: "center",
125
+ paddingHorizontal: 40,
126
+ lineHeight: 20,
127
+ }}
128
+ >
129
+ Voice-first AI communicator.{"\n"}Tap the mic to start talking.
130
+ </Text>
131
+ </View>
72132 </View>
73133 ) : (
74134 <MessageList messages={messages} />
....@@ -76,10 +136,34 @@
76136 </View>
77137
78138 {/* Command bar */}
79
- <CommandBar onCommand={handleCommand} />
139
+ {isTextMode ? (
140
+ <TextModeCommandBar
141
+ onSessions={handleSessions}
142
+ onScreenshot={handleScreenshot}
143
+ onNavigate={handleNavigate}
144
+ onClear={handleClear}
145
+ />
146
+ ) : (
147
+ <CommandBar
148
+ onSessions={handleSessions}
149
+ onScreenshot={handleScreenshot}
150
+ onHelp={handleHelp}
151
+ />
152
+ )}
80153
81154 {/* Input bar */}
82
- <InputBar onSendText={sendTextMessage} onSendVoice={handleSendVoice} />
155
+ <InputBar
156
+ onSendText={sendTextMessage}
157
+ onReplay={handleReplay}
158
+ isTextMode={isTextMode}
159
+ onToggleMode={() => setIsTextMode((v) => !v)}
160
+ />
161
+
162
+ {/* Session picker modal */}
163
+ <SessionPicker
164
+ visible={showSessions}
165
+ onClose={() => setShowSessions(false)}
166
+ />
83167 </SafeAreaView>
84168 );
85169 }
app/navigate.tsx
....@@ -0,0 +1,167 @@
1
+import React, { useEffect } from "react";
2
+import { Image, Pressable, Text, View } from "react-native";
3
+import { SafeAreaView } from "react-native-safe-area-context";
4
+import { router } from "expo-router";
5
+import * as Haptics from "expo-haptics";
6
+import { useChat } from "../contexts/ChatContext";
7
+
8
+interface NavButton {
9
+ label: string;
10
+ key: string;
11
+ icon?: string;
12
+ wide?: boolean;
13
+}
14
+
15
+const NAV_BUTTONS: NavButton[][] = [
16
+ [
17
+ { label: "Esc", key: "escape" },
18
+ { label: "Tab", key: "tab" },
19
+ { label: "Enter", key: "enter" },
20
+ { label: "Ctrl-C", key: "ctrl-c" },
21
+ ],
22
+ [
23
+ { label: "", key: "left", icon: "←" },
24
+ { label: "", key: "up", icon: "↑" },
25
+ { label: "", key: "down", icon: "↓" },
26
+ { label: "", key: "right", icon: "→" },
27
+ ],
28
+];
29
+
30
+export default function NavigateScreen() {
31
+ const { latestScreenshot, requestScreenshot, sendNavKey } = useChat();
32
+
33
+ // Request a screenshot when entering navigation mode
34
+ useEffect(() => {
35
+ requestScreenshot();
36
+ }, [requestScreenshot]);
37
+
38
+ function handleNavPress(key: string) {
39
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
40
+ sendNavKey(key);
41
+ }
42
+
43
+ return (
44
+ <SafeAreaView style={{ flex: 1, backgroundColor: "#0A0A0F" }} edges={["top", "bottom"]}>
45
+ {/* Header */}
46
+ <View
47
+ style={{
48
+ flexDirection: "row",
49
+ alignItems: "center",
50
+ justifyContent: "space-between",
51
+ paddingHorizontal: 16,
52
+ paddingVertical: 10,
53
+ borderBottomWidth: 1,
54
+ borderBottomColor: "#2E2E45",
55
+ }}
56
+ >
57
+ <Pressable
58
+ onPress={() => router.back()}
59
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
60
+ style={{
61
+ width: 36,
62
+ height: 36,
63
+ alignItems: "center",
64
+ justifyContent: "center",
65
+ borderRadius: 18,
66
+ backgroundColor: "#1E1E2E",
67
+ }}
68
+ >
69
+ <Text style={{ color: "#E8E8F0", fontSize: 16 }}>←</Text>
70
+ </Pressable>
71
+ <Text style={{ color: "#E8E8F0", fontSize: 18, fontWeight: "700" }}>
72
+ Navigate
73
+ </Text>
74
+ <Pressable
75
+ onPress={() => requestScreenshot()}
76
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
77
+ style={{
78
+ paddingHorizontal: 12,
79
+ paddingVertical: 8,
80
+ borderRadius: 12,
81
+ backgroundColor: "#1E1E2E",
82
+ }}
83
+ >
84
+ <Text style={{ color: "#4A9EFF", fontSize: 14, fontWeight: "600" }}>
85
+ Refresh
86
+ </Text>
87
+ </Pressable>
88
+ </View>
89
+
90
+ {/* Screenshot area */}
91
+ <View style={{ flex: 1, padding: 8 }}>
92
+ {latestScreenshot ? (
93
+ <Image
94
+ source={{ uri: `data:image/png;base64,${latestScreenshot}` }}
95
+ style={{
96
+ flex: 1,
97
+ borderRadius: 12,
98
+ backgroundColor: "#14141F",
99
+ }}
100
+ resizeMode="contain"
101
+ />
102
+ ) : (
103
+ <View
104
+ style={{
105
+ flex: 1,
106
+ alignItems: "center",
107
+ justifyContent: "center",
108
+ backgroundColor: "#14141F",
109
+ borderRadius: 12,
110
+ }}
111
+ >
112
+ <Text style={{ color: "#5A5A78", fontSize: 16 }}>
113
+ Loading screenshot...
114
+ </Text>
115
+ </View>
116
+ )}
117
+ </View>
118
+
119
+ {/* Navigation buttons */}
120
+ <View
121
+ style={{
122
+ paddingHorizontal: 12,
123
+ paddingBottom: 8,
124
+ gap: 8,
125
+ }}
126
+ >
127
+ {NAV_BUTTONS.map((row, rowIdx) => (
128
+ <View
129
+ key={rowIdx}
130
+ style={{
131
+ flexDirection: "row",
132
+ gap: 8,
133
+ justifyContent: "center",
134
+ }}
135
+ >
136
+ {row.map((btn) => (
137
+ <Pressable
138
+ key={btn.key}
139
+ onPress={() => handleNavPress(btn.key)}
140
+ style={({ pressed }) => ({
141
+ flex: btn.wide ? 2 : 1,
142
+ height: 52,
143
+ borderRadius: 14,
144
+ alignItems: "center",
145
+ justifyContent: "center",
146
+ backgroundColor: pressed ? "#4A9EFF" : "#1E1E2E",
147
+ borderWidth: 1,
148
+ borderColor: pressed ? "#4A9EFF" : "#2E2E45",
149
+ })}
150
+ >
151
+ <Text
152
+ style={{
153
+ color: "#E8E8F0",
154
+ fontSize: btn.icon ? 22 : 15,
155
+ fontWeight: "700",
156
+ }}
157
+ >
158
+ {btn.icon ?? btn.label}
159
+ </Text>
160
+ </Pressable>
161
+ ))}
162
+ </View>
163
+ ))}
164
+ </View>
165
+ </SafeAreaView>
166
+ );
167
+}
app/settings.tsx
....@@ -20,9 +20,9 @@
2020 const { serverConfig, status, connect, disconnect, saveServerConfig } =
2121 useConnection();
2222
23
- const [host, setHost] = useState(serverConfig?.host ?? "");
23
+ const [host, setHost] = useState(serverConfig?.host ?? "192.168.1.100");
2424 const [port, setPort] = useState(
25
- serverConfig?.port ? String(serverConfig.port) : ""
25
+ serverConfig?.port ? String(serverConfig.port) : "8765"
2626 );
2727 const [saved, setSaved] = useState(false);
2828
....@@ -63,14 +63,34 @@
6363 keyboardShouldPersistTaps="handled"
6464 >
6565 {/* Header */}
66
- <View className="flex-row items-center px-4 py-3 border-b border-pai-border">
66
+ <View
67
+ style={{
68
+ flexDirection: "row",
69
+ alignItems: "center",
70
+ paddingHorizontal: 16,
71
+ paddingVertical: 12,
72
+ borderBottomWidth: 1,
73
+ borderBottomColor: "#2E2E45",
74
+ }}
75
+ >
6776 <Pressable
6877 onPress={() => router.back()}
69
- className="w-9 h-9 items-center justify-center rounded-full bg-pai-bg-tertiary mr-3"
78
+ hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
79
+ style={{
80
+ width: 36,
81
+ height: 36,
82
+ alignItems: "center",
83
+ justifyContent: "center",
84
+ borderRadius: 18,
85
+ backgroundColor: "#1E1E2E",
86
+ marginRight: 12,
87
+ }}
7088 >
71
- <Text className="text-pai-text text-base">←</Text>
89
+ <Text style={{ color: "#E8E8F0", fontSize: 16 }}>←</Text>
7290 </Pressable>
73
- <Text className="text-pai-text text-xl font-bold">Settings</Text>
91
+ <Text style={{ color: "#E8E8F0", fontSize: 22, fontWeight: "800", letterSpacing: -0.5 }}>
92
+ Settings
93
+ </Text>
7494 </View>
7595
7696 <View className="px-4 mt-6">
....@@ -115,7 +135,7 @@
115135 autoCapitalize="none"
116136 autoCorrect={false}
117137 keyboardType="url"
118
- className="text-pai-text text-base"
138
+ style={{ color: "#E8E8F0", fontSize: 16, padding: 0 }}
119139 />
120140 </View>
121141
....@@ -130,7 +150,7 @@
130150 placeholder="8765"
131151 placeholderTextColor="#5A5A78"
132152 keyboardType="number-pad"
133
- className="text-pai-text text-base"
153
+ style={{ color: "#E8E8F0", fontSize: 16, padding: 0 }}
134154 />
135155 </View>
136156 </View>
components/SessionPicker.tsx
....@@ -0,0 +1,289 @@
1
+import React, { useCallback, useEffect, useState } from "react";
2
+import {
3
+ Modal,
4
+ Pressable,
5
+ ScrollView,
6
+ Text,
7
+ TextInput,
8
+ View,
9
+} from "react-native";
10
+import * as Haptics from "expo-haptics";
11
+import { WsSession } from "../types";
12
+import { useChat } from "../contexts/ChatContext";
13
+
14
+interface SessionPickerProps {
15
+ visible: boolean;
16
+ onClose: () => void;
17
+}
18
+
19
+export function SessionPicker({ visible, onClose }: SessionPickerProps) {
20
+ const { sessions, requestSessions, switchSession, renameSession } = useChat();
21
+ const [editingId, setEditingId] = useState<string | null>(null);
22
+ const [editName, setEditName] = useState("");
23
+
24
+ useEffect(() => {
25
+ if (visible) {
26
+ requestSessions();
27
+ }
28
+ }, [visible, requestSessions]);
29
+
30
+ const handleSwitch = useCallback(
31
+ (session: WsSession) => {
32
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
33
+ switchSession(session.id);
34
+ onClose();
35
+ },
36
+ [switchSession, onClose]
37
+ );
38
+
39
+ const handleStartRename = useCallback((session: WsSession) => {
40
+ setEditingId(session.id);
41
+ setEditName(session.name);
42
+ }, []);
43
+
44
+ const handleConfirmRename = useCallback(() => {
45
+ if (editingId && editName.trim()) {
46
+ renameSession(editingId, editName.trim());
47
+ }
48
+ setEditingId(null);
49
+ setEditName("");
50
+ }, [editingId, editName, renameSession]);
51
+
52
+ return (
53
+ <Modal
54
+ visible={visible}
55
+ animationType="slide"
56
+ transparent
57
+ onRequestClose={onClose}
58
+ >
59
+ <View
60
+ style={{
61
+ flex: 1,
62
+ backgroundColor: "rgba(0,0,0,0.6)",
63
+ justifyContent: "flex-end",
64
+ }}
65
+ >
66
+ <Pressable
67
+ style={{ flex: 1 }}
68
+ onPress={onClose}
69
+ />
70
+ <View
71
+ style={{
72
+ backgroundColor: "#14141F",
73
+ borderTopLeftRadius: 24,
74
+ borderTopRightRadius: 24,
75
+ maxHeight: "70%",
76
+ paddingBottom: 40,
77
+ }}
78
+ >
79
+ {/* Handle bar */}
80
+ <View style={{ alignItems: "center", paddingTop: 12, paddingBottom: 8 }}>
81
+ <View
82
+ style={{
83
+ width: 40,
84
+ height: 4,
85
+ borderRadius: 2,
86
+ backgroundColor: "#2E2E45",
87
+ }}
88
+ />
89
+ </View>
90
+
91
+ {/* Header */}
92
+ <View
93
+ style={{
94
+ flexDirection: "row",
95
+ alignItems: "center",
96
+ justifyContent: "space-between",
97
+ paddingHorizontal: 20,
98
+ paddingBottom: 16,
99
+ }}
100
+ >
101
+ <Text
102
+ style={{
103
+ color: "#E8E8F0",
104
+ fontSize: 20,
105
+ fontWeight: "700",
106
+ }}
107
+ >
108
+ Sessions
109
+ </Text>
110
+ <Pressable
111
+ onPress={() => requestSessions()}
112
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
113
+ style={{
114
+ paddingHorizontal: 12,
115
+ paddingVertical: 6,
116
+ borderRadius: 12,
117
+ backgroundColor: "#1E1E2E",
118
+ }}
119
+ >
120
+ <Text style={{ color: "#9898B0", fontSize: 13 }}>Refresh</Text>
121
+ </Pressable>
122
+ </View>
123
+
124
+ {/* Session list */}
125
+ <ScrollView
126
+ style={{ paddingHorizontal: 16 }}
127
+ showsVerticalScrollIndicator={false}
128
+ >
129
+ {sessions.length === 0 ? (
130
+ <View style={{ alignItems: "center", paddingVertical: 32 }}>
131
+ <Text style={{ color: "#5A5A78", fontSize: 15 }}>
132
+ No sessions found
133
+ </Text>
134
+ </View>
135
+ ) : (
136
+ sessions.map((session) => (
137
+ <View key={session.id} style={{ marginBottom: 8 }}>
138
+ {editingId === session.id ? (
139
+ /* Rename mode */
140
+ <View
141
+ style={{
142
+ backgroundColor: "#1E1E2E",
143
+ borderRadius: 16,
144
+ padding: 16,
145
+ borderWidth: 2,
146
+ borderColor: "#4A9EFF",
147
+ }}
148
+ >
149
+ <TextInput
150
+ value={editName}
151
+ onChangeText={setEditName}
152
+ autoFocus
153
+ onSubmitEditing={handleConfirmRename}
154
+ returnKeyType="done"
155
+ style={{
156
+ color: "#E8E8F0",
157
+ fontSize: 17,
158
+ fontWeight: "600",
159
+ padding: 0,
160
+ marginBottom: 12,
161
+ }}
162
+ placeholderTextColor="#5A5A78"
163
+ placeholder="Session name..."
164
+ />
165
+ <View style={{ flexDirection: "row", gap: 8 }}>
166
+ <Pressable
167
+ onPress={handleConfirmRename}
168
+ style={{
169
+ flex: 1,
170
+ backgroundColor: "#4A9EFF",
171
+ borderRadius: 10,
172
+ paddingVertical: 10,
173
+ alignItems: "center",
174
+ }}
175
+ >
176
+ <Text style={{ color: "#FFF", fontSize: 15, fontWeight: "600" }}>
177
+ Save
178
+ </Text>
179
+ </Pressable>
180
+ <Pressable
181
+ onPress={() => setEditingId(null)}
182
+ style={{
183
+ flex: 1,
184
+ backgroundColor: "#252538",
185
+ borderRadius: 10,
186
+ paddingVertical: 10,
187
+ alignItems: "center",
188
+ }}
189
+ >
190
+ <Text style={{ color: "#9898B0", fontSize: 15 }}>Cancel</Text>
191
+ </Pressable>
192
+ </View>
193
+ </View>
194
+ ) : (
195
+ /* Normal session row */
196
+ <Pressable
197
+ onPress={() => handleSwitch(session)}
198
+ onLongPress={() => handleStartRename(session)}
199
+ style={({ pressed }) => ({
200
+ backgroundColor: pressed ? "#252538" : "#1E1E2E",
201
+ borderRadius: 16,
202
+ padding: 16,
203
+ flexDirection: "row",
204
+ alignItems: "center",
205
+ borderWidth: session.isActive ? 2 : 1,
206
+ borderColor: session.isActive ? "#4A9EFF" : "#2E2E45",
207
+ })}
208
+ >
209
+ {/* Number badge */}
210
+ <View
211
+ style={{
212
+ width: 36,
213
+ height: 36,
214
+ borderRadius: 18,
215
+ backgroundColor: session.isActive ? "#4A9EFF" : "#252538",
216
+ alignItems: "center",
217
+ justifyContent: "center",
218
+ marginRight: 14,
219
+ }}
220
+ >
221
+ <Text
222
+ style={{
223
+ color: session.isActive ? "#FFF" : "#9898B0",
224
+ fontSize: 16,
225
+ fontWeight: "700",
226
+ }}
227
+ >
228
+ {session.index}
229
+ </Text>
230
+ </View>
231
+
232
+ {/* Session info */}
233
+ <View style={{ flex: 1 }}>
234
+ <Text
235
+ style={{
236
+ color: "#E8E8F0",
237
+ fontSize: 17,
238
+ fontWeight: "600",
239
+ }}
240
+ numberOfLines={1}
241
+ >
242
+ {session.name}
243
+ </Text>
244
+ <Text
245
+ style={{
246
+ color: "#5A5A78",
247
+ fontSize: 12,
248
+ marginTop: 2,
249
+ }}
250
+ >
251
+ {session.type === "terminal" ? "Terminal" : "Claude"}
252
+ {session.isActive ? " — active" : ""}
253
+ </Text>
254
+ </View>
255
+
256
+ {/* Active indicator */}
257
+ {session.isActive && (
258
+ <View
259
+ style={{
260
+ width: 10,
261
+ height: 10,
262
+ borderRadius: 5,
263
+ backgroundColor: "#2ED573",
264
+ }}
265
+ />
266
+ )}
267
+ </Pressable>
268
+ )}
269
+ </View>
270
+ ))
271
+ )}
272
+
273
+ {/* Hint */}
274
+ <Text
275
+ style={{
276
+ color: "#5A5A78",
277
+ fontSize: 12,
278
+ textAlign: "center",
279
+ paddingVertical: 12,
280
+ }}
281
+ >
282
+ Tap to switch — Long press to rename
283
+ </Text>
284
+ </ScrollView>
285
+ </View>
286
+ </View>
287
+ </Modal>
288
+ );
289
+}
components/chat/CommandBar.tsx
....@@ -1,67 +1,103 @@
11 import React, { useState } from "react";
2
-import { Pressable, ScrollView, Text, View } from "react-native";
3
-
4
-interface Command {
5
- label: string;
6
- value: string;
7
-}
8
-
9
-const DEFAULT_COMMANDS: Command[] = [
10
- { label: "/s", value: "/s" },
11
- { label: "/ss", value: "/ss" },
12
- { label: "/clear", value: "/clear" },
13
- { label: "/help", value: "/help" },
14
- { label: "/status", value: "/status" },
15
-];
2
+import { Pressable, Text, View, useWindowDimensions } from "react-native";
3
+import * as Haptics from "expo-haptics";
164
175 interface CommandBarProps {
18
- onCommand: (command: string) => void;
19
- commands?: Command[];
6
+ onSessions: () => void;
7
+ onScreenshot: () => void;
8
+ onHelp: () => void;
209 }
2110
22
-export function CommandBar({
23
- onCommand,
24
- commands = DEFAULT_COMMANDS,
25
-}: CommandBarProps) {
26
- const [activeCommand, setActiveCommand] = useState<string | null>(null);
11
+export function CommandBar({ onSessions, onScreenshot, onHelp }: CommandBarProps) {
12
+ return (
13
+ <View
14
+ style={{
15
+ flexDirection: "row",
16
+ paddingHorizontal: 12,
17
+ paddingVertical: 6,
18
+ gap: 8,
19
+ }}
20
+ >
21
+ <CmdBtn icon="📋" label="Sessions" bg="#1A2744" border="#2E4A7A" onPress={onSessions} />
22
+ <CmdBtn icon="📸" label="Screen" bg="#1A3A2A" border="#2E6A4A" onPress={onScreenshot} />
23
+ <CmdBtn icon="❓" label="Help" bg="#3A1A2A" border="#6A2E4A" onPress={onHelp} />
24
+ </View>
25
+ );
26
+}
2727
28
- function handlePress(command: Command) {
29
- setActiveCommand(command.value);
30
- onCommand(command.value);
31
- setTimeout(() => setActiveCommand(null), 200);
32
- }
28
+interface TextModeCommandBarProps {
29
+ onSessions: () => void;
30
+ onScreenshot: () => void;
31
+ onNavigate: () => void;
32
+ onClear: () => void;
33
+}
34
+
35
+export function TextModeCommandBar({
36
+ onSessions,
37
+ onScreenshot,
38
+ onNavigate,
39
+ onClear,
40
+}: TextModeCommandBarProps) {
41
+ return (
42
+ <View
43
+ style={{
44
+ flexDirection: "row",
45
+ paddingHorizontal: 12,
46
+ paddingVertical: 6,
47
+ gap: 8,
48
+ }}
49
+ >
50
+ <CmdBtn icon="📋" label="Sessions" bg="#1A2744" border="#2E4A7A" onPress={onSessions} />
51
+ <CmdBtn icon="📸" label="Screen" bg="#1A3A2A" border="#2E6A4A" onPress={onScreenshot} />
52
+ <CmdBtn icon="🧭" label="Navigate" bg="#2A2A1A" border="#5A5A2E" onPress={onNavigate} />
53
+ <CmdBtn icon="🗑" label="Clear" bg="#3A1A1A" border="#6A2E2E" onPress={onClear} />
54
+ </View>
55
+ );
56
+}
57
+
58
+function CmdBtn({
59
+ icon,
60
+ label,
61
+ bg,
62
+ border,
63
+ onPress,
64
+}: {
65
+ icon: string;
66
+ label: string;
67
+ bg: string;
68
+ border: string;
69
+ onPress: () => void;
70
+}) {
71
+ const [pressed, setPressed] = useState(false);
72
+ const { width } = useWindowDimensions();
3373
3474 return (
35
- <View className="border-t border-pai-border">
36
- <ScrollView
37
- horizontal
38
- showsHorizontalScrollIndicator={false}
39
- contentContainerStyle={{ paddingHorizontal: 12, paddingVertical: 8, gap: 8 }}
75
+ <View style={{ flex: 1 }}>
76
+ <Pressable
77
+ onPressIn={() => setPressed(true)}
78
+ onPressOut={() => setPressed(false)}
79
+ onPress={() => {
80
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
81
+ onPress();
82
+ }}
4083 >
41
- {commands.map((cmd) => (
42
- <Pressable
43
- key={cmd.value}
44
- onPress={() => handlePress(cmd)}
45
- className="rounded-full px-4 py-2"
46
- style={({ pressed }) => ({
47
- backgroundColor:
48
- activeCommand === cmd.value || pressed
49
- ? "#4A9EFF"
50
- : "#1E1E2E",
51
- })}
52
- >
53
- <Text
54
- className="text-sm font-medium"
55
- style={{
56
- color:
57
- activeCommand === cmd.value ? "#FFFFFF" : "#9898B0",
58
- }}
59
- >
60
- {cmd.label}
61
- </Text>
62
- </Pressable>
63
- ))}
64
- </ScrollView>
84
+ <View
85
+ style={{
86
+ height: 68,
87
+ borderRadius: 16,
88
+ alignItems: "center",
89
+ justifyContent: "center",
90
+ backgroundColor: pressed ? "#4A9EFF" : bg,
91
+ borderWidth: 1.5,
92
+ borderColor: pressed ? "#4A9EFF" : border,
93
+ }}
94
+ >
95
+ <Text style={{ fontSize: 26, marginBottom: 2 }}>{icon}</Text>
96
+ <Text style={{ color: "#C8C8E0", fontSize: 13, fontWeight: "700" }}>
97
+ {label}
98
+ </Text>
99
+ </View>
100
+ </Pressable>
65101 </View>
66102 );
67103 }
components/chat/InputBar.tsx
....@@ -6,16 +6,23 @@
66 TextInput,
77 View,
88 } from "react-native";
9
+import * as Haptics from "expo-haptics";
910 import { VoiceButton } from "./VoiceButton";
1011
1112 interface InputBarProps {
1213 onSendText: (text: string) => void;
13
- onSendVoice: (audioUri: string, durationMs: number) => void;
14
+ onReplay: () => void;
15
+ isTextMode: boolean;
16
+ onToggleMode: () => void;
1417 }
1518
16
-export function InputBar({ onSendText, onSendVoice }: InputBarProps) {
19
+export function InputBar({
20
+ onSendText,
21
+ onReplay,
22
+ isTextMode,
23
+ onToggleMode,
24
+}: InputBarProps) {
1725 const [text, setText] = useState("");
18
- const [isVoiceMode, setIsVoiceMode] = useState(false);
1926 const inputRef = useRef<TextInput>(null);
2027
2128 const handleSend = useCallback(() => {
....@@ -25,42 +32,108 @@
2532 setText("");
2633 }, [text, onSendText]);
2734
28
- const toggleMode = useCallback(() => {
29
- setIsVoiceMode((prev) => {
30
- if (prev) {
31
- // Switching to text mode — focus input after mode switch
32
- setTimeout(() => inputRef.current?.focus(), 100);
33
- } else {
34
- Keyboard.dismiss();
35
- }
36
- return !prev;
37
- });
38
- }, []);
39
-
40
- if (isVoiceMode) {
35
+ if (!isTextMode) {
36
+ // Voice mode: [Replay] [Talk] [Aa]
4137 return (
42
- <View className="border-t border-pai-border bg-pai-bg">
43
- {/* Mode toggle */}
38
+ <View
39
+ style={{
40
+ flexDirection: "row",
41
+ gap: 10,
42
+ paddingHorizontal: 16,
43
+ paddingVertical: 10,
44
+ paddingBottom: 6,
45
+ borderTopWidth: 1,
46
+ borderTopColor: "#2E2E45",
47
+ alignItems: "center",
48
+ }}
49
+ >
50
+ {/* Replay last message */}
4451 <Pressable
45
- onPress={toggleMode}
46
- className="absolute top-3 right-4 z-10 w-10 h-10 items-center justify-center"
52
+ onPress={() => {
53
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
54
+ onReplay();
55
+ }}
4756 >
48
- <Text className="text-2xl">⌨️</Text>
57
+ <View
58
+ style={{
59
+ width: 68,
60
+ height: 68,
61
+ borderRadius: 34,
62
+ alignItems: "center",
63
+ justifyContent: "center",
64
+ backgroundColor: "#1A2E1A",
65
+ borderWidth: 1.5,
66
+ borderColor: "#3A6A3A",
67
+ }}
68
+ >
69
+ <Text style={{ fontSize: 24 }}>▶</Text>
70
+ <Text style={{ color: "#8ABF8A", fontSize: 10, marginTop: 1, fontWeight: "600" }}>Replay</Text>
71
+ </View>
4972 </Pressable>
5073
51
- <VoiceButton onVoiceMessage={onSendVoice} />
74
+ {/* Talk button — center, biggest */}
75
+ <View style={{ flex: 1, alignItems: "center" }}>
76
+ <VoiceButton onTranscript={onSendText} />
77
+ </View>
78
+
79
+ {/* Text mode toggle */}
80
+ <Pressable
81
+ onPress={() => {
82
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
83
+ onToggleMode();
84
+ setTimeout(() => inputRef.current?.focus(), 150);
85
+ }}
86
+ >
87
+ <View
88
+ style={{
89
+ width: 68,
90
+ height: 68,
91
+ borderRadius: 34,
92
+ alignItems: "center",
93
+ justifyContent: "center",
94
+ backgroundColor: "#1A1A3E",
95
+ borderWidth: 1.5,
96
+ borderColor: "#3A3A7A",
97
+ }}
98
+ >
99
+ <Text style={{ fontSize: 22, color: "#9898D0", fontWeight: "700" }}>Aa</Text>
100
+ </View>
101
+ </Pressable>
52102 </View>
53103 );
54104 }
55105
106
+ // Text mode: [Mic] [TextInput] [Send]
56107 return (
57
- <View className="border-t border-pai-border bg-pai-bg px-3 py-2 flex-row items-end gap-2">
108
+ <View
109
+ style={{
110
+ flexDirection: "row",
111
+ gap: 8,
112
+ paddingHorizontal: 12,
113
+ paddingVertical: 8,
114
+ borderTopWidth: 1,
115
+ borderTopColor: "#2E2E45",
116
+ alignItems: "flex-end",
117
+ }}
118
+ >
58119 {/* Voice mode toggle */}
59120 <Pressable
60
- onPress={toggleMode}
61
- className="w-10 h-10 items-center justify-center rounded-full bg-pai-bg-tertiary mb-0.5"
121
+ onPress={() => {
122
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
123
+ Keyboard.dismiss();
124
+ onToggleMode();
125
+ }}
126
+ style={{
127
+ width: 40,
128
+ height: 40,
129
+ borderRadius: 20,
130
+ alignItems: "center",
131
+ justifyContent: "center",
132
+ backgroundColor: "#1E1E2E",
133
+ marginBottom: 2,
134
+ }}
62135 >
63
- <Text className="text-xl">🎤</Text>
136
+ <Text style={{ fontSize: 20 }}>🎤</Text>
64137 </Pressable>
65138
66139 {/* Text input */}
....@@ -75,19 +148,39 @@
75148 onSubmitEditing={handleSend}
76149 returnKeyType="send"
77150 blurOnSubmit
78
- className="flex-1 bg-pai-bg-tertiary rounded-2xl px-4 py-2.5 text-pai-text text-base"
79
- style={{ maxHeight: 120 }}
151
+ style={{
152
+ flex: 1,
153
+ backgroundColor: "#1E1E2E",
154
+ borderRadius: 20,
155
+ paddingHorizontal: 16,
156
+ paddingVertical: 10,
157
+ maxHeight: 120,
158
+ color: "#E8E8F0",
159
+ fontSize: 16,
160
+ }}
80161 />
81162
82163 {/* Send button */}
83164 <Pressable
84165 onPress={handleSend}
85166 disabled={!text.trim()}
86
- className={`w-10 h-10 rounded-full items-center justify-center mb-0.5 ${
87
- text.trim() ? "bg-pai-accent" : "bg-pai-bg-tertiary"
88
- }`}
167
+ style={{
168
+ width: 40,
169
+ height: 40,
170
+ borderRadius: 20,
171
+ alignItems: "center",
172
+ justifyContent: "center",
173
+ marginBottom: 2,
174
+ backgroundColor: text.trim() ? "#4A9EFF" : "#1E1E2E",
175
+ }}
89176 >
90
- <Text className={`text-xl ${text.trim() ? "text-white" : "text-pai-text-muted"}`}>
177
+ <Text
178
+ style={{
179
+ fontSize: 18,
180
+ fontWeight: "bold",
181
+ color: text.trim() ? "#FFFFFF" : "#5A5A78",
182
+ }}
183
+ >
91184
92185 </Text>
93186 </Pressable>
components/chat/MessageBubble.tsx
....@@ -1,5 +1,5 @@
11 import React, { useCallback, useState } from "react";
2
-import { Pressable, Text, View } from "react-native";
2
+import { Image, Pressable, Text, View } from "react-native";
33 import { Message } from "../../types";
44 import { playAudio, stopPlayback } from "../../services/audio";
55
....@@ -57,7 +57,32 @@
5757 : "bg-pai-surface rounded-tl-sm"
5858 }`}
5959 >
60
- {message.type === "voice" ? (
60
+ {message.type === "image" && message.imageBase64 ? (
61
+ /* Image message */
62
+ <View>
63
+ <Image
64
+ source={{ uri: `data:image/png;base64,${message.imageBase64}` }}
65
+ style={{
66
+ width: 260,
67
+ height: 180,
68
+ borderRadius: 10,
69
+ backgroundColor: "#14141F",
70
+ }}
71
+ resizeMode="contain"
72
+ />
73
+ {message.content ? (
74
+ <Text
75
+ style={{
76
+ color: isUser ? "#FFF" : "#9898B0",
77
+ fontSize: 12,
78
+ marginTop: 4,
79
+ }}
80
+ >
81
+ {message.content}
82
+ </Text>
83
+ ) : null}
84
+ </View>
85
+ ) : message.type === "voice" ? (
6186 <Pressable
6287 onPress={handleVoicePress}
6388 className="flex-row items-center gap-3"
components/chat/VoiceButton.tsx
....@@ -1,121 +1,192 @@
1
-import React, { useCallback, useRef, useState } from "react";
1
+import React, { useCallback, useEffect, useRef, useState } from "react";
22 import { Animated, Pressable, Text, View } from "react-native";
33 import * as Haptics from "expo-haptics";
4
-import { startRecording, stopRecording } from "../../services/audio";
5
-import { Audio } from "expo-av";
4
+import {
5
+ ExpoSpeechRecognitionModule,
6
+ useSpeechRecognitionEvent,
7
+} from "expo-speech-recognition";
68
79 interface VoiceButtonProps {
8
- onVoiceMessage: (audioUri: string, durationMs: number) => void;
10
+ onTranscript: (text: string) => void;
911 }
1012
11
-const VOICE_BUTTON_SIZE = 88;
13
+const VOICE_BUTTON_SIZE = 72;
1214
13
-export function VoiceButton({ onVoiceMessage }: VoiceButtonProps) {
14
- const [isRecording, setIsRecording] = useState(false);
15
- const recordingRef = useRef<Audio.Recording | null>(null);
16
- const scaleAnim = useRef(new Animated.Value(1)).current;
15
+/**
16
+ * Tap-to-toggle voice button using on-device speech recognition.
17
+ * - Tap once: start listening
18
+ * - Tap again: stop and send transcript
19
+ * - Long-press while listening: cancel (discard)
20
+ */
21
+export function VoiceButton({ onTranscript }: VoiceButtonProps) {
22
+ const [isListening, setIsListening] = useState(false);
23
+ const [transcript, setTranscript] = useState("");
1724 const pulseAnim = useRef(new Animated.Value(1)).current;
25
+ const glowAnim = useRef(new Animated.Value(0)).current;
1826 const pulseLoop = useRef<Animated.CompositeAnimation | null>(null);
27
+ const cancelledRef = useRef(false);
28
+
29
+ // Speech recognition events
30
+ useSpeechRecognitionEvent("start", () => {
31
+ setIsListening(true);
32
+ });
33
+
34
+ useSpeechRecognitionEvent("end", () => {
35
+ setIsListening(false);
36
+ stopPulse();
37
+
38
+ // Send transcript if we have one and weren't cancelled
39
+ if (!cancelledRef.current && transcript.trim()) {
40
+ onTranscript(transcript.trim());
41
+ }
42
+ setTranscript("");
43
+ cancelledRef.current = false;
44
+ });
45
+
46
+ useSpeechRecognitionEvent("result", (event) => {
47
+ const text = event.results[0]?.transcript ?? "";
48
+ setTranscript(text);
49
+ });
50
+
51
+ useSpeechRecognitionEvent("error", (event) => {
52
+ console.error("Speech recognition error:", event.error, event.message);
53
+ setIsListening(false);
54
+ stopPulse();
55
+ setTranscript("");
56
+ });
1957
2058 const startPulse = useCallback(() => {
2159 pulseLoop.current = Animated.loop(
2260 Animated.sequence([
2361 Animated.timing(pulseAnim, {
2462 toValue: 1.15,
25
- duration: 600,
63
+ duration: 700,
2664 useNativeDriver: true,
2765 }),
2866 Animated.timing(pulseAnim, {
2967 toValue: 1,
30
- duration: 600,
68
+ duration: 700,
3169 useNativeDriver: true,
3270 }),
3371 ])
3472 );
3573 pulseLoop.current.start();
36
- }, [pulseAnim]);
74
+ Animated.timing(glowAnim, {
75
+ toValue: 1,
76
+ duration: 300,
77
+ useNativeDriver: true,
78
+ }).start();
79
+ }, [pulseAnim, glowAnim]);
3780
3881 const stopPulse = useCallback(() => {
3982 pulseLoop.current?.stop();
4083 pulseAnim.setValue(1);
41
- }, [pulseAnim]);
42
-
43
- const handlePressIn = useCallback(async () => {
44
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
45
-
46
- Animated.spring(scaleAnim, {
47
- toValue: 0.92,
84
+ Animated.timing(glowAnim, {
85
+ toValue: 0,
86
+ duration: 200,
4887 useNativeDriver: true,
4988 }).start();
89
+ }, [pulseAnim, glowAnim]);
5090
51
- const recording = await startRecording();
52
- if (recording) {
53
- recordingRef.current = recording;
54
- setIsRecording(true);
55
- startPulse();
56
- }
57
- }, [scaleAnim, startPulse]);
91
+ const startListening = useCallback(async () => {
92
+ const result = await ExpoSpeechRecognitionModule.requestPermissionsAsync();
93
+ if (!result.granted) return;
5894
59
- const handlePressOut = useCallback(async () => {
60
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
95
+ cancelledRef.current = false;
96
+ setTranscript("");
97
+ startPulse();
6198
62
- Animated.spring(scaleAnim, {
63
- toValue: 1,
64
- useNativeDriver: true,
65
- }).start();
99
+ ExpoSpeechRecognitionModule.start({
100
+ lang: "en-US",
101
+ interimResults: true,
102
+ continuous: true,
103
+ });
104
+ }, [startPulse]);
66105
106
+ const stopAndSend = useCallback(() => {
67107 stopPulse();
68
- setIsRecording(false);
108
+ cancelledRef.current = false;
109
+ ExpoSpeechRecognitionModule.stop();
110
+ }, [stopPulse]);
69111
70
- if (recordingRef.current) {
71
- const result = await stopRecording();
72
- recordingRef.current = null;
112
+ const cancelListening = useCallback(() => {
113
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
114
+ stopPulse();
115
+ cancelledRef.current = true;
116
+ setTranscript("");
117
+ ExpoSpeechRecognitionModule.abort();
118
+ }, [stopPulse]);
73119
74
- if (result && result.durationMs > 500) {
75
- onVoiceMessage(result.uri, result.durationMs);
76
- }
120
+ const handleTap = useCallback(async () => {
121
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
122
+ if (isListening) {
123
+ stopAndSend();
124
+ } else {
125
+ await startListening();
77126 }
78
- }, [scaleAnim, stopPulse, onVoiceMessage]);
127
+ }, [isListening, stopAndSend, startListening]);
128
+
129
+ const handleLongPress = useCallback(() => {
130
+ if (isListening) {
131
+ cancelListening();
132
+ }
133
+ }, [isListening, cancelListening]);
79134
80135 return (
81
- <View className="items-center justify-center py-4">
82
- {/* Pulse ring — only visible while recording */}
136
+ <View style={{ alignItems: "center", justifyContent: "center" }}>
137
+ {/* Outer pulse ring */}
83138 <Animated.View
84139 style={{
85140 position: "absolute",
86141 width: VOICE_BUTTON_SIZE + 24,
87142 height: VOICE_BUTTON_SIZE + 24,
88143 borderRadius: (VOICE_BUTTON_SIZE + 24) / 2,
89
- backgroundColor: isRecording ? "rgba(255, 159, 67, 0.15)" : "transparent",
144
+ backgroundColor: isListening ? "rgba(255, 159, 67, 0.12)" : "transparent",
90145 transform: [{ scale: pulseAnim }],
146
+ opacity: glowAnim,
91147 }}
92148 />
93149
94150 {/* Button */}
95
- <Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
96
- <Pressable
97
- onPressIn={handlePressIn}
98
- onPressOut={handlePressOut}
151
+ <Pressable
152
+ onPress={handleTap}
153
+ onLongPress={handleLongPress}
154
+ delayLongPress={600}
155
+ >
156
+ <View
99157 style={{
100158 width: VOICE_BUTTON_SIZE,
101159 height: VOICE_BUTTON_SIZE,
102160 borderRadius: VOICE_BUTTON_SIZE / 2,
103
- backgroundColor: isRecording ? "#FF9F43" : "#4A9EFF",
161
+ backgroundColor: isListening ? "#FF9F43" : "#4A9EFF",
104162 alignItems: "center",
105163 justifyContent: "center",
106
- shadowColor: isRecording ? "#FF9F43" : "#4A9EFF",
164
+ shadowColor: isListening ? "#FF9F43" : "#4A9EFF",
107165 shadowOffset: { width: 0, height: 4 },
108166 shadowOpacity: 0.4,
109167 shadowRadius: 12,
110168 elevation: 8,
111169 }}
112170 >
113
- <Text style={{ fontSize: 32 }}>{isRecording ? "🎙" : "🎤"}</Text>
114
- </Pressable>
115
- </Animated.View>
171
+ <Text style={{ fontSize: 28 }}>{isListening ? "⏹" : "🎤"}</Text>
172
+ </View>
173
+ </Pressable>
116174
117
- <Text className="text-pai-text-muted text-xs mt-3">
118
- {isRecording ? "Release to send" : "Hold to talk"}
175
+ {/* Label / transcript preview */}
176
+ <Text
177
+ style={{
178
+ color: isListening ? "#FF9F43" : "#5A5A78",
179
+ fontSize: 11,
180
+ marginTop: 4,
181
+ fontWeight: isListening ? "600" : "400",
182
+ maxWidth: 200,
183
+ textAlign: "center",
184
+ }}
185
+ numberOfLines={2}
186
+ >
187
+ {isListening
188
+ ? transcript || "Listening..."
189
+ : "Tap to talk"}
119190 </Text>
120191 </View>
121192 );
contexts/ChatContext.tsx
....@@ -6,9 +6,9 @@
66 useRef,
77 useState,
88 } from "react";
9
-import { Message, WebSocketMessage } from "../types";
9
+import { Message, WsIncoming, WsSession } from "../types";
1010 import { useConnection } from "./ConnectionContext";
11
-import { playAudio } from "../services/audio";
11
+import { playAudio, encodeAudioToBase64 } from "../services/audio";
1212
1313 function generateId(): string {
1414 return Date.now().toString(36) + Math.random().toString(36).slice(2);
....@@ -19,13 +19,29 @@
1919 sendTextMessage: (text: string) => void;
2020 sendVoiceMessage: (audioUri: string, durationMs?: number) => void;
2121 clearMessages: () => void;
22
+ // Session management
23
+ sessions: WsSession[];
24
+ requestSessions: () => void;
25
+ switchSession: (sessionId: string) => void;
26
+ renameSession: (sessionId: string, name: string) => void;
27
+ // Screenshot / navigation
28
+ latestScreenshot: string | null;
29
+ requestScreenshot: () => void;
30
+ sendNavKey: (key: string) => void;
2231 }
2332
2433 const ChatContext = createContext<ChatContextValue | null>(null);
2534
2635 export function ChatProvider({ children }: { children: React.ReactNode }) {
2736 const [messages, setMessages] = useState<Message[]>([]);
28
- const { sendTextMessage: wsSend, sendVoiceMessage: wsVoice, onMessageReceived } = useConnection();
37
+ const [sessions, setSessions] = useState<WsSession[]>([]);
38
+ const [latestScreenshot, setLatestScreenshot] = useState<string | null>(null);
39
+ const {
40
+ sendTextMessage: wsSend,
41
+ sendVoiceMessage: wsVoice,
42
+ sendCommand,
43
+ onMessageReceived,
44
+ } = useConnection();
2945
3046 const addMessage = useCallback((msg: Message) => {
3147 setMessages((prev) => [...prev, msg]);
....@@ -42,34 +58,92 @@
4258
4359 // Handle incoming WebSocket messages
4460 useEffect(() => {
45
- onMessageReceived.current = (data: WebSocketMessage) => {
46
- if (data.type === "text") {
47
- const msg: Message = {
48
- id: generateId(),
49
- role: "assistant",
50
- type: "text",
51
- content: data.content,
52
- timestamp: Date.now(),
53
- status: "sent",
54
- };
55
- setMessages((prev) => [...prev, msg]);
56
- } else if (data.type === "voice") {
57
- const msg: Message = {
58
- id: generateId(),
59
- role: "assistant",
60
- type: "voice",
61
- content: data.content ?? "",
62
- audioUri: data.audioBase64
63
- ? `data:audio/mp4;base64,${data.audioBase64}`
64
- : undefined,
65
- timestamp: Date.now(),
66
- status: "sent",
67
- };
68
- setMessages((prev) => [...prev, msg]);
69
-
70
- // Auto-play incoming voice messages
71
- if (msg.audioUri) {
72
- playAudio(msg.audioUri).catch(() => {});
61
+ onMessageReceived.current = (data: WsIncoming) => {
62
+ switch (data.type) {
63
+ case "text": {
64
+ const msg: Message = {
65
+ id: generateId(),
66
+ role: "assistant",
67
+ type: "text",
68
+ content: data.content,
69
+ timestamp: Date.now(),
70
+ status: "sent",
71
+ };
72
+ setMessages((prev) => [...prev, msg]);
73
+ break;
74
+ }
75
+ case "voice": {
76
+ const msg: Message = {
77
+ id: generateId(),
78
+ role: "assistant",
79
+ type: "voice",
80
+ content: data.content ?? "",
81
+ audioUri: data.audioBase64
82
+ ? `data:audio/mp4;base64,${data.audioBase64}`
83
+ : undefined,
84
+ timestamp: Date.now(),
85
+ status: "sent",
86
+ };
87
+ setMessages((prev) => [...prev, msg]);
88
+ if (msg.audioUri) {
89
+ playAudio(msg.audioUri).catch(() => {});
90
+ }
91
+ break;
92
+ }
93
+ case "image": {
94
+ // Store as latest screenshot for navigation mode
95
+ setLatestScreenshot(data.imageBase64);
96
+ // Also add to chat as an image message
97
+ const msg: Message = {
98
+ id: generateId(),
99
+ role: "assistant",
100
+ type: "image",
101
+ content: data.caption ?? "Screenshot",
102
+ imageBase64: data.imageBase64,
103
+ timestamp: Date.now(),
104
+ status: "sent",
105
+ };
106
+ setMessages((prev) => [...prev, msg]);
107
+ break;
108
+ }
109
+ case "sessions": {
110
+ setSessions(data.sessions);
111
+ break;
112
+ }
113
+ case "session_switched": {
114
+ const msg: Message = {
115
+ id: generateId(),
116
+ role: "system",
117
+ type: "text",
118
+ content: `Switched to ${data.name}`,
119
+ timestamp: Date.now(),
120
+ };
121
+ setMessages((prev) => [...prev, msg]);
122
+ break;
123
+ }
124
+ case "session_renamed": {
125
+ const msg: Message = {
126
+ id: generateId(),
127
+ role: "system",
128
+ type: "text",
129
+ content: `Renamed to ${data.name}`,
130
+ timestamp: Date.now(),
131
+ };
132
+ setMessages((prev) => [...prev, msg]);
133
+ // Refresh sessions to show updated name
134
+ sendCommand("sessions");
135
+ break;
136
+ }
137
+ case "error": {
138
+ const msg: Message = {
139
+ id: generateId(),
140
+ role: "system",
141
+ type: "text",
142
+ content: data.message,
143
+ timestamp: Date.now(),
144
+ };
145
+ setMessages((prev) => [...prev, msg]);
146
+ break;
73147 }
74148 }
75149 };
....@@ -77,7 +151,7 @@
77151 return () => {
78152 onMessageReceived.current = null;
79153 };
80
- }, [onMessageReceived]);
154
+ }, [onMessageReceived, sendCommand]);
81155
82156 const sendTextMessage = useCallback(
83157 (text: string) => {
....@@ -91,7 +165,6 @@
91165 status: "sending",
92166 };
93167 addMessage(msg);
94
-
95168 const sent = wsSend(text);
96169 updateMessageStatus(id, sent ? "sent" : "error");
97170 },
....@@ -99,7 +172,7 @@
99172 );
100173
101174 const sendVoiceMessage = useCallback(
102
- (audioUri: string, durationMs?: number) => {
175
+ async (audioUri: string, durationMs?: number) => {
103176 const id = generateId();
104177 const msg: Message = {
105178 id,
....@@ -112,10 +185,14 @@
112185 duration: durationMs,
113186 };
114187 addMessage(msg);
115
-
116
- // For now, send with empty base64 since we'd need expo-file-system to encode
117
- const sent = wsVoice("", "Voice message");
118
- updateMessageStatus(id, sent ? "sent" : "error");
188
+ try {
189
+ const base64 = await encodeAudioToBase64(audioUri);
190
+ const sent = wsVoice(base64);
191
+ updateMessageStatus(id, sent ? "sent" : "error");
192
+ } catch (err) {
193
+ console.error("Failed to encode audio:", err);
194
+ updateMessageStatus(id, "error");
195
+ }
119196 },
120197 [wsVoice, addMessage, updateMessageStatus]
121198 );
....@@ -124,9 +201,52 @@
124201 setMessages([]);
125202 }, []);
126203
204
+ // --- Session management ---
205
+ const requestSessions = useCallback(() => {
206
+ sendCommand("sessions");
207
+ }, [sendCommand]);
208
+
209
+ const switchSession = useCallback(
210
+ (sessionId: string) => {
211
+ sendCommand("switch", { sessionId });
212
+ },
213
+ [sendCommand]
214
+ );
215
+
216
+ const renameSession = useCallback(
217
+ (sessionId: string, name: string) => {
218
+ sendCommand("rename", { sessionId, name });
219
+ },
220
+ [sendCommand]
221
+ );
222
+
223
+ // --- Screenshot / navigation ---
224
+ const requestScreenshot = useCallback(() => {
225
+ sendCommand("screenshot");
226
+ }, [sendCommand]);
227
+
228
+ const sendNavKey = useCallback(
229
+ (key: string) => {
230
+ sendCommand("nav", { key });
231
+ },
232
+ [sendCommand]
233
+ );
234
+
127235 return (
128236 <ChatContext.Provider
129
- value={{ messages, sendTextMessage, sendVoiceMessage, clearMessages }}
237
+ value={{
238
+ messages,
239
+ sendTextMessage,
240
+ sendVoiceMessage,
241
+ clearMessages,
242
+ sessions,
243
+ requestSessions,
244
+ switchSession,
245
+ renameSession,
246
+ latestScreenshot,
247
+ requestScreenshot,
248
+ sendNavKey,
249
+ }}
130250 >
131251 {children}
132252 </ChatContext.Provider>
contexts/ConnectionContext.tsx
....@@ -7,7 +7,12 @@
77 useState,
88 } from "react";
99 import * as SecureStore from "expo-secure-store";
10
-import { ConnectionStatus, ServerConfig, WebSocketMessage } from "../types";
10
+import {
11
+ ConnectionStatus,
12
+ ServerConfig,
13
+ WsIncoming,
14
+ WsOutgoing,
15
+} from "../types";
1116 import { wsClient } from "../services/websocket";
1217
1318 const SECURE_STORE_KEY = "pailot_server_config";
....@@ -19,9 +24,10 @@
1924 disconnect: () => void;
2025 sendTextMessage: (text: string) => boolean;
2126 sendVoiceMessage: (audioBase64: string, transcript?: string) => boolean;
27
+ sendCommand: (command: string, args?: Record<string, unknown>) => boolean;
2228 saveServerConfig: (config: ServerConfig) => Promise<void>;
2329 onMessageReceived: React.MutableRefObject<
24
- ((data: WebSocketMessage) => void) | null
30
+ ((data: WsIncoming) => void) | null
2531 >;
2632 }
2733
....@@ -34,9 +40,7 @@
3440 }) {
3541 const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
3642 const [status, setStatus] = useState<ConnectionStatus>("disconnected");
37
- const onMessageReceived = useRef<((data: WebSocketMessage) => void) | null>(
38
- null
39
- );
43
+ const onMessageReceived = useRef<((data: WsIncoming) => void) | null>(null);
4044
4145 useEffect(() => {
4246 loadConfig();
....@@ -48,7 +52,7 @@
4852 onClose: () => setStatus("disconnected"),
4953 onError: () => setStatus("disconnected"),
5054 onMessage: (data) => {
51
- onMessageReceived.current?.(data);
55
+ onMessageReceived.current?.(data as WsIncoming);
5256 },
5357 });
5458 }, []);
....@@ -92,18 +96,24 @@
9296 }, []);
9397
9498 const sendTextMessage = useCallback((text: string): boolean => {
95
- const msg: WebSocketMessage = { type: "text", content: text };
96
- return wsClient.send(msg);
99
+ return wsClient.send({ type: "text", content: text });
97100 }, []);
98101
99102 const sendVoiceMessage = useCallback(
100103 (audioBase64: string, transcript: string = ""): boolean => {
101
- const msg: WebSocketMessage = {
104
+ return wsClient.send({
102105 type: "voice",
103106 content: transcript,
104107 audioBase64,
105
- };
106
- return wsClient.send(msg);
108
+ });
109
+ },
110
+ []
111
+ );
112
+
113
+ const sendCommand = useCallback(
114
+ (command: string, args?: Record<string, unknown>): boolean => {
115
+ const msg: WsOutgoing = { type: "command", command, args };
116
+ return wsClient.send(msg as any);
107117 },
108118 []
109119 );
....@@ -117,6 +127,7 @@
117127 disconnect,
118128 sendTextMessage,
119129 sendVoiceMessage,
130
+ sendCommand,
120131 saveServerConfig,
121132 onMessageReceived,
122133 }}
package-lock.json
....@@ -8,24 +8,37 @@
88 "name": "pailot",
99 "version": "1.0.0",
1010 "dependencies": {
11
+ "@react-navigation/bottom-tabs": "^7.15.3",
12
+ "@react-navigation/native": "^7.1.31",
1113 "expo": "~55.0.4",
12
- "expo-av": "^16.0.8",
14
+ "expo-audio": "^55.0.8",
15
+ "expo-constants": "~55.0.7",
16
+ "expo-file-system": "~55.0.10",
1317 "expo-haptics": "~55.0.8",
18
+ "expo-linking": "~55.0.7",
1419 "expo-router": "~55.0.3",
1520 "expo-secure-store": "~55.0.8",
21
+ "expo-speech-recognition": "^3.1.1",
22
+ "expo-splash-screen": "~55.0.10",
1623 "expo-status-bar": "~55.0.4",
24
+ "expo-system-ui": "~55.0.9",
25
+ "expo-web-browser": "~55.0.9",
1726 "nativewind": "^4",
1827 "react": "19.2.0",
28
+ "react-dom": "^19.2.4",
1929 "react-native": "0.83.2",
2030 "react-native-gesture-handler": "~2.30.0",
2131 "react-native-reanimated": "4.2.1",
2232 "react-native-safe-area-context": "~5.6.2",
2333 "react-native-screens": "~4.23.0",
24
- "react-native-svg": "15.15.3"
34
+ "react-native-svg": "15.15.3",
35
+ "react-native-web": "^0.21.0",
36
+ "react-native-worklets": "0.7.2"
2537 },
2638 "devDependencies": {
2739 "@types/react": "~19.2.2",
2840 "babel-plugin-module-resolver": "^5.0.2",
41
+ "babel-preset-expo": "^55.0.10",
2942 "tailwindcss": "^3.4.19",
3043 "typescript": "~5.9.2"
3144 }
....@@ -1317,6 +1330,21 @@
13171330 "@babel/core": "^7.0.0-0"
13181331 }
13191332 },
1333
+ "node_modules/@babel/plugin-transform-template-literals": {
1334
+ "version": "7.27.1",
1335
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz",
1336
+ "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==",
1337
+ "license": "MIT",
1338
+ "dependencies": {
1339
+ "@babel/helper-plugin-utils": "^7.27.1"
1340
+ },
1341
+ "engines": {
1342
+ "node": ">=6.9.0"
1343
+ },
1344
+ "peerDependencies": {
1345
+ "@babel/core": "^7.0.0-0"
1346
+ }
1347
+ },
13201348 "node_modules/@babel/plugin-transform-typescript": {
13211349 "version": "7.28.6",
13221350 "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz",
....@@ -1733,6 +1761,27 @@
17331761 "@xmldom/xmldom": "^0.8.8",
17341762 "base64-js": "^1.5.1",
17351763 "xmlbuilder": "^15.1.1"
1764
+ }
1765
+ },
1766
+ "node_modules/@expo/prebuild-config": {
1767
+ "version": "55.0.8",
1768
+ "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-55.0.8.tgz",
1769
+ "integrity": "sha512-VJNJiOmmZgyDnR7JMmc3B8Z0ZepZ17I8Wtw+wAH/2+UCUsFg588XU+bwgYcFGw+is28kwGjY46z43kfufpxOnA==",
1770
+ "license": "MIT",
1771
+ "dependencies": {
1772
+ "@expo/config": "~55.0.8",
1773
+ "@expo/config-plugins": "~55.0.6",
1774
+ "@expo/config-types": "^55.0.5",
1775
+ "@expo/image-utils": "^0.8.12",
1776
+ "@expo/json-file": "^10.0.12",
1777
+ "@react-native/normalize-colors": "0.83.2",
1778
+ "debug": "^4.3.1",
1779
+ "resolve-from": "^5.0.0",
1780
+ "semver": "^7.6.0",
1781
+ "xml2js": "0.6.0"
1782
+ },
1783
+ "peerDependencies": {
1784
+ "expo": "*"
17361785 }
17371786 },
17381787 "node_modules/@expo/require-utils": {
....@@ -3466,6 +3515,54 @@
34663515 "@babel/core": "^7.0.0 || ^8.0.0-0"
34673516 }
34683517 },
3518
+ "node_modules/babel-preset-expo": {
3519
+ "version": "55.0.10",
3520
+ "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-55.0.10.tgz",
3521
+ "integrity": "sha512-aRtW7qJKohGU2V0LUJ6IeP7py3+kVUo9zcc8+v1Kix8jGGuIvqvpo9S6W1Fmn9VFP2DBwkFDLiyzkCZS85urVA==",
3522
+ "license": "MIT",
3523
+ "dependencies": {
3524
+ "@babel/generator": "^7.20.5",
3525
+ "@babel/helper-module-imports": "^7.25.9",
3526
+ "@babel/plugin-proposal-decorators": "^7.12.9",
3527
+ "@babel/plugin-proposal-export-default-from": "^7.24.7",
3528
+ "@babel/plugin-syntax-export-default-from": "^7.24.7",
3529
+ "@babel/plugin-transform-class-static-block": "^7.27.1",
3530
+ "@babel/plugin-transform-export-namespace-from": "^7.25.9",
3531
+ "@babel/plugin-transform-flow-strip-types": "^7.25.2",
3532
+ "@babel/plugin-transform-modules-commonjs": "^7.24.8",
3533
+ "@babel/plugin-transform-object-rest-spread": "^7.24.7",
3534
+ "@babel/plugin-transform-parameters": "^7.24.7",
3535
+ "@babel/plugin-transform-private-methods": "^7.24.7",
3536
+ "@babel/plugin-transform-private-property-in-object": "^7.24.7",
3537
+ "@babel/plugin-transform-runtime": "^7.24.7",
3538
+ "@babel/preset-react": "^7.22.15",
3539
+ "@babel/preset-typescript": "^7.23.0",
3540
+ "@react-native/babel-preset": "0.83.2",
3541
+ "babel-plugin-react-compiler": "^1.0.0",
3542
+ "babel-plugin-react-native-web": "~0.21.0",
3543
+ "babel-plugin-syntax-hermes-parser": "^0.32.0",
3544
+ "babel-plugin-transform-flow-enums": "^0.0.2",
3545
+ "debug": "^4.3.4",
3546
+ "resolve-from": "^5.0.0"
3547
+ },
3548
+ "peerDependencies": {
3549
+ "@babel/runtime": "^7.20.0",
3550
+ "expo": "*",
3551
+ "expo-widgets": "^55.0.2",
3552
+ "react-refresh": ">=0.14.0 <1.0.0"
3553
+ },
3554
+ "peerDependenciesMeta": {
3555
+ "@babel/runtime": {
3556
+ "optional": true
3557
+ },
3558
+ "expo": {
3559
+ "optional": true
3560
+ },
3561
+ "expo-widgets": {
3562
+ "optional": true
3563
+ }
3564
+ }
3565
+ },
34693566 "node_modules/babel-preset-jest": {
34703567 "version": "29.6.3",
34713568 "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz",
....@@ -4048,6 +4145,15 @@
40484145 "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
40494146 "license": "MIT"
40504147 },
4148
+ "node_modules/cross-fetch": {
4149
+ "version": "3.2.0",
4150
+ "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
4151
+ "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==",
4152
+ "license": "MIT",
4153
+ "dependencies": {
4154
+ "node-fetch": "^2.7.0"
4155
+ }
4156
+ },
40514157 "node_modules/cross-spawn": {
40524158 "version": "7.0.6",
40534159 "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
....@@ -4060,6 +4166,15 @@
40604166 },
40614167 "engines": {
40624168 "node": ">= 8"
4169
+ }
4170
+ },
4171
+ "node_modules/css-in-js-utils": {
4172
+ "version": "3.1.0",
4173
+ "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
4174
+ "integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==",
4175
+ "license": "MIT",
4176
+ "dependencies": {
4177
+ "hyphenate-style-name": "^1.0.3"
40634178 }
40644179 },
40654180 "node_modules/css-select": {
....@@ -4457,21 +4572,16 @@
44574572 }
44584573 }
44594574 },
4460
- "node_modules/expo-av": {
4461
- "version": "16.0.8",
4462
- "resolved": "https://registry.npmjs.org/expo-av/-/expo-av-16.0.8.tgz",
4463
- "integrity": "sha512-cmVPftGR/ca7XBgs7R6ky36lF3OC0/MM/lpgX/yXqfv0jASTsh7AYX9JxHCwFmF+Z6JEB1vne9FDx4GiLcGreQ==",
4575
+ "node_modules/expo-audio": {
4576
+ "version": "55.0.8",
4577
+ "resolved": "https://registry.npmjs.org/expo-audio/-/expo-audio-55.0.8.tgz",
4578
+ "integrity": "sha512-X61pQSikE2rsP2ZTMFUMThOmgGyYEHcmZpGVMrKJgcYtRCFKuctB/z69dFQPoumL+zTz8qlBoGohjkHVvA9P8A==",
44644579 "license": "MIT",
44654580 "peerDependencies": {
44664581 "expo": "*",
4582
+ "expo-asset": "*",
44674583 "react": "*",
4468
- "react-native": "*",
4469
- "react-native-web": "*"
4470
- },
4471
- "peerDependenciesMeta": {
4472
- "react-native-web": {
4473
- "optional": true
4474
- }
4584
+ "react-native": "*"
44754585 }
44764586 },
44774587 "node_modules/expo-constants": {
....@@ -4483,6 +4593,16 @@
44834593 "@expo/config": "~55.0.8",
44844594 "@expo/env": "~2.1.1"
44854595 },
4596
+ "peerDependencies": {
4597
+ "expo": "*",
4598
+ "react-native": "*"
4599
+ }
4600
+ },
4601
+ "node_modules/expo-file-system": {
4602
+ "version": "55.0.10",
4603
+ "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-55.0.10.tgz",
4604
+ "integrity": "sha512-ysFdVdUgtfj2ApY0Cn+pBg+yK4xp+SNwcaH8j2B91JJQ4OXJmnyCSmrNZYz7J4mdYVuv2GzxIP+N/IGlHQG3Yw==",
4605
+ "license": "MIT",
44864606 "peerDependencies": {
44874607 "expo": "*",
44884608 "react-native": "*"
....@@ -4540,6 +4660,20 @@
45404660 "react-native-web": {
45414661 "optional": true
45424662 }
4663
+ }
4664
+ },
4665
+ "node_modules/expo-linking": {
4666
+ "version": "55.0.7",
4667
+ "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-55.0.7.tgz",
4668
+ "integrity": "sha512-MiGCedere1vzQTEi2aGrkzd7eh/rPSz4w6F3GMBuAJzYl+/0VhIuyhozpEGrueyDIXWfzaUVOcn3SfxVi+kwQQ==",
4669
+ "license": "MIT",
4670
+ "dependencies": {
4671
+ "expo-constants": "~55.0.7",
4672
+ "invariant": "^2.2.4"
4673
+ },
4674
+ "peerDependencies": {
4675
+ "react": "*",
4676
+ "react-native": "*"
45434677 }
45444678 },
45454679 "node_modules/expo-modules-autolinking": {
....@@ -4729,6 +4863,29 @@
47294863 "node": ">=20.16.0"
47304864 }
47314865 },
4866
+ "node_modules/expo-speech-recognition": {
4867
+ "version": "3.1.1",
4868
+ "resolved": "https://registry.npmjs.org/expo-speech-recognition/-/expo-speech-recognition-3.1.1.tgz",
4869
+ "integrity": "sha512-+1rviv+ZecAokY8PUfr3XJuhS4t0uKccewIPPUk5ooeEt5xKEWr6XYpKm3ggapPdJQbgMTjWbmSPT1ahTMyIqA==",
4870
+ "license": "MIT",
4871
+ "peerDependencies": {
4872
+ "expo": "*",
4873
+ "react": "*",
4874
+ "react-native": "*"
4875
+ }
4876
+ },
4877
+ "node_modules/expo-splash-screen": {
4878
+ "version": "55.0.10",
4879
+ "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-55.0.10.tgz",
4880
+ "integrity": "sha512-RN5qqrxudxFlRIjLFr/Ifmt+mUCLRc0gs66PekP6flzNS/JYEuoCbwJ+NmUwwJtPA+vyy60DYiky0QmS98ydmQ==",
4881
+ "license": "MIT",
4882
+ "dependencies": {
4883
+ "@expo/prebuild-config": "^55.0.8"
4884
+ },
4885
+ "peerDependencies": {
4886
+ "expo": "*"
4887
+ }
4888
+ },
47324889 "node_modules/expo-status-bar": {
47334890 "version": "55.0.4",
47344891 "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-55.0.4.tgz",
....@@ -4755,6 +4912,36 @@
47554912 "expo": "*",
47564913 "expo-font": "*",
47574914 "react": "*",
4915
+ "react-native": "*"
4916
+ }
4917
+ },
4918
+ "node_modules/expo-system-ui": {
4919
+ "version": "55.0.9",
4920
+ "resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-55.0.9.tgz",
4921
+ "integrity": "sha512-8ygP1B0uFAFI8s7eHY2IcGnE83GhFeZYwHBr/fQ4dSXnc7iVT9zp2PvyTyiDiibQ69dBG+fauMQ4KlPcOO51kQ==",
4922
+ "license": "MIT",
4923
+ "dependencies": {
4924
+ "@react-native/normalize-colors": "0.83.2",
4925
+ "debug": "^4.3.2"
4926
+ },
4927
+ "peerDependencies": {
4928
+ "expo": "*",
4929
+ "react-native": "*",
4930
+ "react-native-web": "*"
4931
+ },
4932
+ "peerDependenciesMeta": {
4933
+ "react-native-web": {
4934
+ "optional": true
4935
+ }
4936
+ }
4937
+ },
4938
+ "node_modules/expo-web-browser": {
4939
+ "version": "55.0.9",
4940
+ "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-55.0.9.tgz",
4941
+ "integrity": "sha512-PvAVsG401QmZabtTsYh1cYcpPiqvBPs8oiOkSrp0jIXnneiM466HxmeNtvo+fNxqJ2nwOBz9qLPiWRO91VBfsQ==",
4942
+ "license": "MIT",
4943
+ "peerDependencies": {
4944
+ "expo": "*",
47584945 "react-native": "*"
47594946 }
47604947 },
....@@ -4839,27 +5026,6 @@
48395026 }
48405027 }
48415028 },
4842
- "node_modules/expo/node_modules/@expo/cli/node_modules/@expo/prebuild-config": {
4843
- "version": "55.0.8",
4844
- "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-55.0.8.tgz",
4845
- "integrity": "sha512-VJNJiOmmZgyDnR7JMmc3B8Z0ZepZ17I8Wtw+wAH/2+UCUsFg588XU+bwgYcFGw+is28kwGjY46z43kfufpxOnA==",
4846
- "license": "MIT",
4847
- "dependencies": {
4848
- "@expo/config": "~55.0.8",
4849
- "@expo/config-plugins": "~55.0.6",
4850
- "@expo/config-types": "^55.0.5",
4851
- "@expo/image-utils": "^0.8.12",
4852
- "@expo/json-file": "^10.0.12",
4853
- "@react-native/normalize-colors": "0.83.2",
4854
- "debug": "^4.3.1",
4855
- "resolve-from": "^5.0.0",
4856
- "semver": "^7.6.0",
4857
- "xml2js": "0.6.0"
4858
- },
4859
- "peerDependencies": {
4860
- "expo": "*"
4861
- }
4862
- },
48635029 "node_modules/expo/node_modules/@expo/cli/node_modules/@expo/router-server": {
48645030 "version": "55.0.9",
48655031 "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-55.0.9.tgz",
....@@ -4940,54 +5106,6 @@
49405106 "react-native": "*"
49415107 }
49425108 },
4943
- "node_modules/expo/node_modules/babel-preset-expo": {
4944
- "version": "55.0.10",
4945
- "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-55.0.10.tgz",
4946
- "integrity": "sha512-aRtW7qJKohGU2V0LUJ6IeP7py3+kVUo9zcc8+v1Kix8jGGuIvqvpo9S6W1Fmn9VFP2DBwkFDLiyzkCZS85urVA==",
4947
- "license": "MIT",
4948
- "dependencies": {
4949
- "@babel/generator": "^7.20.5",
4950
- "@babel/helper-module-imports": "^7.25.9",
4951
- "@babel/plugin-proposal-decorators": "^7.12.9",
4952
- "@babel/plugin-proposal-export-default-from": "^7.24.7",
4953
- "@babel/plugin-syntax-export-default-from": "^7.24.7",
4954
- "@babel/plugin-transform-class-static-block": "^7.27.1",
4955
- "@babel/plugin-transform-export-namespace-from": "^7.25.9",
4956
- "@babel/plugin-transform-flow-strip-types": "^7.25.2",
4957
- "@babel/plugin-transform-modules-commonjs": "^7.24.8",
4958
- "@babel/plugin-transform-object-rest-spread": "^7.24.7",
4959
- "@babel/plugin-transform-parameters": "^7.24.7",
4960
- "@babel/plugin-transform-private-methods": "^7.24.7",
4961
- "@babel/plugin-transform-private-property-in-object": "^7.24.7",
4962
- "@babel/plugin-transform-runtime": "^7.24.7",
4963
- "@babel/preset-react": "^7.22.15",
4964
- "@babel/preset-typescript": "^7.23.0",
4965
- "@react-native/babel-preset": "0.83.2",
4966
- "babel-plugin-react-compiler": "^1.0.0",
4967
- "babel-plugin-react-native-web": "~0.21.0",
4968
- "babel-plugin-syntax-hermes-parser": "^0.32.0",
4969
- "babel-plugin-transform-flow-enums": "^0.0.2",
4970
- "debug": "^4.3.4",
4971
- "resolve-from": "^5.0.0"
4972
- },
4973
- "peerDependencies": {
4974
- "@babel/runtime": "^7.20.0",
4975
- "expo": "*",
4976
- "expo-widgets": "^55.0.2",
4977
- "react-refresh": ">=0.14.0 <1.0.0"
4978
- },
4979
- "peerDependenciesMeta": {
4980
- "@babel/runtime": {
4981
- "optional": true
4982
- },
4983
- "expo": {
4984
- "optional": true
4985
- },
4986
- "expo-widgets": {
4987
- "optional": true
4988
- }
4989
- }
4990
- },
49915109 "node_modules/expo/node_modules/ci-info": {
49925110 "version": "3.9.0",
49935111 "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
....@@ -5015,16 +5133,6 @@
50155133 "peerDependencies": {
50165134 "expo": "*",
50175135 "react": "*",
5018
- "react-native": "*"
5019
- }
5020
- },
5021
- "node_modules/expo/node_modules/expo-file-system": {
5022
- "version": "55.0.10",
5023
- "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-55.0.10.tgz",
5024
- "integrity": "sha512-ysFdVdUgtfj2ApY0Cn+pBg+yK4xp+SNwcaH8j2B91JJQ4OXJmnyCSmrNZYz7J4mdYVuv2GzxIP+N/IGlHQG3Yw==",
5025
- "license": "MIT",
5026
- "peerDependencies": {
5027
- "expo": "*",
50285136 "react-native": "*"
50295137 }
50305138 },
....@@ -5148,6 +5256,36 @@
51485256 "license": "Apache-2.0",
51495257 "dependencies": {
51505258 "bser": "2.1.1"
5259
+ }
5260
+ },
5261
+ "node_modules/fbjs": {
5262
+ "version": "3.0.5",
5263
+ "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz",
5264
+ "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==",
5265
+ "license": "MIT",
5266
+ "dependencies": {
5267
+ "cross-fetch": "^3.1.5",
5268
+ "fbjs-css-vars": "^1.0.0",
5269
+ "loose-envify": "^1.0.0",
5270
+ "object-assign": "^4.1.0",
5271
+ "promise": "^7.1.1",
5272
+ "setimmediate": "^1.0.5",
5273
+ "ua-parser-js": "^1.0.35"
5274
+ }
5275
+ },
5276
+ "node_modules/fbjs-css-vars": {
5277
+ "version": "1.0.2",
5278
+ "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz",
5279
+ "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==",
5280
+ "license": "MIT"
5281
+ },
5282
+ "node_modules/fbjs/node_modules/promise": {
5283
+ "version": "7.3.1",
5284
+ "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
5285
+ "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
5286
+ "license": "MIT",
5287
+ "dependencies": {
5288
+ "asap": "~2.0.3"
51515289 }
51525290 },
51535291 "node_modules/fetch-nodeshim": {
....@@ -5481,6 +5619,12 @@
54815619 "node": ">= 14"
54825620 }
54835621 },
5622
+ "node_modules/hyphenate-style-name": {
5623
+ "version": "1.1.0",
5624
+ "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz",
5625
+ "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
5626
+ "license": "BSD-3-Clause"
5627
+ },
54845628 "node_modules/ignore": {
54855629 "version": "5.3.2",
54865630 "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
....@@ -5530,6 +5674,15 @@
55305674 "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
55315675 "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
55325676 "license": "ISC"
5677
+ },
5678
+ "node_modules/inline-style-prefixer": {
5679
+ "version": "7.0.1",
5680
+ "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz",
5681
+ "integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==",
5682
+ "license": "MIT",
5683
+ "dependencies": {
5684
+ "css-in-js-utils": "^3.1.0"
5685
+ }
55335686 },
55345687 "node_modules/invariant": {
55355688 "version": "2.2.4",
....@@ -6840,6 +6993,26 @@
68406993 "node": ">= 0.6"
68416994 }
68426995 },
6996
+ "node_modules/node-fetch": {
6997
+ "version": "2.7.0",
6998
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
6999
+ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
7000
+ "license": "MIT",
7001
+ "dependencies": {
7002
+ "whatwg-url": "^5.0.0"
7003
+ },
7004
+ "engines": {
7005
+ "node": "4.x || >=6.0.0"
7006
+ },
7007
+ "peerDependencies": {
7008
+ "encoding": "^0.1.0"
7009
+ },
7010
+ "peerDependenciesMeta": {
7011
+ "encoding": {
7012
+ "optional": true
7013
+ }
7014
+ }
7015
+ },
68437016 "node_modules/node-forge": {
68447017 "version": "1.3.3",
68457018 "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz",
....@@ -6919,7 +7092,6 @@
69197092 "version": "4.1.1",
69207093 "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
69217094 "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
6922
- "dev": true,
69237095 "license": "MIT",
69247096 "engines": {
69257097 "node": ">=0.10.0"
....@@ -7499,7 +7671,6 @@
74997671 "version": "4.2.0",
75007672 "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
75017673 "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
7502
- "dev": true,
75037674 "license": "MIT"
75047675 },
75057676 "node_modules/pretty-format": {
....@@ -7642,6 +7813,18 @@
76427813 "dependencies": {
76437814 "shell-quote": "^1.6.1",
76447815 "ws": "^7"
7816
+ }
7817
+ },
7818
+ "node_modules/react-dom": {
7819
+ "version": "19.2.4",
7820
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
7821
+ "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
7822
+ "license": "MIT",
7823
+ "dependencies": {
7824
+ "scheduler": "^0.27.0"
7825
+ },
7826
+ "peerDependencies": {
7827
+ "react": "^19.2.4"
76457828 }
76467829 },
76477830 "node_modules/react-fast-compare": {
....@@ -8086,6 +8269,160 @@
80868269 "peerDependencies": {
80878270 "react": "*",
80888271 "react-native": "*"
8272
+ }
8273
+ },
8274
+ "node_modules/react-native-web": {
8275
+ "version": "0.21.2",
8276
+ "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
8277
+ "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==",
8278
+ "license": "MIT",
8279
+ "dependencies": {
8280
+ "@babel/runtime": "^7.18.6",
8281
+ "@react-native/normalize-colors": "^0.74.1",
8282
+ "fbjs": "^3.0.4",
8283
+ "inline-style-prefixer": "^7.0.1",
8284
+ "memoize-one": "^6.0.0",
8285
+ "nullthrows": "^1.1.1",
8286
+ "postcss-value-parser": "^4.2.0",
8287
+ "styleq": "^0.1.3"
8288
+ },
8289
+ "peerDependencies": {
8290
+ "react": "^18.0.0 || ^19.0.0",
8291
+ "react-dom": "^18.0.0 || ^19.0.0"
8292
+ }
8293
+ },
8294
+ "node_modules/react-native-web/node_modules/@react-native/normalize-colors": {
8295
+ "version": "0.74.89",
8296
+ "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz",
8297
+ "integrity": "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==",
8298
+ "license": "MIT"
8299
+ },
8300
+ "node_modules/react-native-web/node_modules/memoize-one": {
8301
+ "version": "6.0.0",
8302
+ "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
8303
+ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
8304
+ "license": "MIT"
8305
+ },
8306
+ "node_modules/react-native-worklets": {
8307
+ "version": "0.7.2",
8308
+ "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.2.tgz",
8309
+ "integrity": "sha512-DuLu1kMV/Uyl9pQHp3hehAlThoLw7Yk2FwRTpzASOmI+cd4845FWn3m2bk9MnjUw8FBRIyhwLqYm2AJaXDXsog==",
8310
+ "license": "MIT",
8311
+ "dependencies": {
8312
+ "@babel/plugin-transform-arrow-functions": "7.27.1",
8313
+ "@babel/plugin-transform-class-properties": "7.27.1",
8314
+ "@babel/plugin-transform-classes": "7.28.4",
8315
+ "@babel/plugin-transform-nullish-coalescing-operator": "7.27.1",
8316
+ "@babel/plugin-transform-optional-chaining": "7.27.1",
8317
+ "@babel/plugin-transform-shorthand-properties": "7.27.1",
8318
+ "@babel/plugin-transform-template-literals": "7.27.1",
8319
+ "@babel/plugin-transform-unicode-regex": "7.27.1",
8320
+ "@babel/preset-typescript": "7.27.1",
8321
+ "convert-source-map": "2.0.0",
8322
+ "semver": "7.7.3"
8323
+ },
8324
+ "peerDependencies": {
8325
+ "@babel/core": "*",
8326
+ "react": "*",
8327
+ "react-native": "*"
8328
+ }
8329
+ },
8330
+ "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-class-properties": {
8331
+ "version": "7.27.1",
8332
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz",
8333
+ "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==",
8334
+ "license": "MIT",
8335
+ "dependencies": {
8336
+ "@babel/helper-create-class-features-plugin": "^7.27.1",
8337
+ "@babel/helper-plugin-utils": "^7.27.1"
8338
+ },
8339
+ "engines": {
8340
+ "node": ">=6.9.0"
8341
+ },
8342
+ "peerDependencies": {
8343
+ "@babel/core": "^7.0.0-0"
8344
+ }
8345
+ },
8346
+ "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-classes": {
8347
+ "version": "7.28.4",
8348
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz",
8349
+ "integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==",
8350
+ "license": "MIT",
8351
+ "dependencies": {
8352
+ "@babel/helper-annotate-as-pure": "^7.27.3",
8353
+ "@babel/helper-compilation-targets": "^7.27.2",
8354
+ "@babel/helper-globals": "^7.28.0",
8355
+ "@babel/helper-plugin-utils": "^7.27.1",
8356
+ "@babel/helper-replace-supers": "^7.27.1",
8357
+ "@babel/traverse": "^7.28.4"
8358
+ },
8359
+ "engines": {
8360
+ "node": ">=6.9.0"
8361
+ },
8362
+ "peerDependencies": {
8363
+ "@babel/core": "^7.0.0-0"
8364
+ }
8365
+ },
8366
+ "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-nullish-coalescing-operator": {
8367
+ "version": "7.27.1",
8368
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz",
8369
+ "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==",
8370
+ "license": "MIT",
8371
+ "dependencies": {
8372
+ "@babel/helper-plugin-utils": "^7.27.1"
8373
+ },
8374
+ "engines": {
8375
+ "node": ">=6.9.0"
8376
+ },
8377
+ "peerDependencies": {
8378
+ "@babel/core": "^7.0.0-0"
8379
+ }
8380
+ },
8381
+ "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-optional-chaining": {
8382
+ "version": "7.27.1",
8383
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz",
8384
+ "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==",
8385
+ "license": "MIT",
8386
+ "dependencies": {
8387
+ "@babel/helper-plugin-utils": "^7.27.1",
8388
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
8389
+ },
8390
+ "engines": {
8391
+ "node": ">=6.9.0"
8392
+ },
8393
+ "peerDependencies": {
8394
+ "@babel/core": "^7.0.0-0"
8395
+ }
8396
+ },
8397
+ "node_modules/react-native-worklets/node_modules/@babel/preset-typescript": {
8398
+ "version": "7.27.1",
8399
+ "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz",
8400
+ "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==",
8401
+ "license": "MIT",
8402
+ "dependencies": {
8403
+ "@babel/helper-plugin-utils": "^7.27.1",
8404
+ "@babel/helper-validator-option": "^7.27.1",
8405
+ "@babel/plugin-syntax-jsx": "^7.27.1",
8406
+ "@babel/plugin-transform-modules-commonjs": "^7.27.1",
8407
+ "@babel/plugin-transform-typescript": "^7.27.1"
8408
+ },
8409
+ "engines": {
8410
+ "node": ">=6.9.0"
8411
+ },
8412
+ "peerDependencies": {
8413
+ "@babel/core": "^7.0.0-0"
8414
+ }
8415
+ },
8416
+ "node_modules/react-native-worklets/node_modules/semver": {
8417
+ "version": "7.7.3",
8418
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
8419
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
8420
+ "license": "ISC",
8421
+ "bin": {
8422
+ "semver": "bin/semver.js"
8423
+ },
8424
+ "engines": {
8425
+ "node": ">=10"
80898426 }
80908427 },
80918428 "node_modules/react-native/node_modules/@react-native/virtualized-lists": {
....@@ -8648,6 +8985,12 @@
86488985 "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
86498986 "license": "MIT"
86508987 },
8988
+ "node_modules/setimmediate": {
8989
+ "version": "1.0.5",
8990
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
8991
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
8992
+ "license": "MIT"
8993
+ },
86518994 "node_modules/setprototypeof": {
86528995 "version": "1.2.0",
86538996 "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
....@@ -8900,6 +9243,12 @@
89009243 "version": "0.4.1",
89019244 "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz",
89029245 "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==",
9246
+ "license": "MIT"
9247
+ },
9248
+ "node_modules/styleq": {
9249
+ "version": "0.1.3",
9250
+ "resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz",
9251
+ "integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==",
89039252 "license": "MIT"
89049253 },
89059254 "node_modules/sucrase": {
....@@ -9223,6 +9572,12 @@
92239572 "integrity": "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==",
92249573 "license": "MIT"
92259574 },
9575
+ "node_modules/tr46": {
9576
+ "version": "0.0.3",
9577
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
9578
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
9579
+ "license": "MIT"
9580
+ },
92269581 "node_modules/ts-interface-checker": {
92279582 "version": "0.1.13",
92289583 "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
....@@ -9266,6 +9621,32 @@
92669621 },
92679622 "engines": {
92689623 "node": ">=14.17"
9624
+ }
9625
+ },
9626
+ "node_modules/ua-parser-js": {
9627
+ "version": "1.0.41",
9628
+ "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz",
9629
+ "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==",
9630
+ "funding": [
9631
+ {
9632
+ "type": "opencollective",
9633
+ "url": "https://opencollective.com/ua-parser-js"
9634
+ },
9635
+ {
9636
+ "type": "paypal",
9637
+ "url": "https://paypal.me/faisalman"
9638
+ },
9639
+ {
9640
+ "type": "github",
9641
+ "url": "https://github.com/sponsors/faisalman"
9642
+ }
9643
+ ],
9644
+ "license": "MIT",
9645
+ "bin": {
9646
+ "ua-parser-js": "script/cli.js"
9647
+ },
9648
+ "engines": {
9649
+ "node": "*"
92699650 }
92709651 },
92719652 "node_modules/undici-types": {
....@@ -9500,12 +9881,28 @@
95009881 "defaults": "^1.0.3"
95019882 }
95029883 },
9884
+ "node_modules/webidl-conversions": {
9885
+ "version": "3.0.1",
9886
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
9887
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
9888
+ "license": "BSD-2-Clause"
9889
+ },
95039890 "node_modules/whatwg-fetch": {
95049891 "version": "3.6.20",
95059892 "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
95069893 "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
95079894 "license": "MIT"
95089895 },
9896
+ "node_modules/whatwg-url": {
9897
+ "version": "5.0.0",
9898
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
9899
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
9900
+ "license": "MIT",
9901
+ "dependencies": {
9902
+ "tr46": "~0.0.3",
9903
+ "webidl-conversions": "^3.0.0"
9904
+ }
9905
+ },
95099906 "node_modules/whatwg-url-minimum": {
95109907 "version": "0.1.1",
95119908 "resolved": "https://registry.npmjs.org/whatwg-url-minimum/-/whatwg-url-minimum-0.1.1.tgz",
package.json
....@@ -4,29 +4,42 @@
44 "main": "expo-router/entry",
55 "scripts": {
66 "start": "expo start",
7
- "android": "expo start --android",
8
- "ios": "expo start --ios",
7
+ "android": "expo run:android",
8
+ "ios": "expo run:ios",
99 "web": "expo start --web"
1010 },
1111 "dependencies": {
12
+ "@react-navigation/bottom-tabs": "^7.15.3",
13
+ "@react-navigation/native": "^7.1.31",
1214 "expo": "~55.0.4",
13
- "expo-av": "^16.0.8",
15
+ "expo-audio": "^55.0.8",
16
+ "expo-constants": "~55.0.7",
17
+ "expo-file-system": "~55.0.10",
1418 "expo-haptics": "~55.0.8",
19
+ "expo-linking": "~55.0.7",
1520 "expo-router": "~55.0.3",
1621 "expo-secure-store": "~55.0.8",
22
+ "expo-speech-recognition": "^3.1.1",
23
+ "expo-splash-screen": "~55.0.10",
1724 "expo-status-bar": "~55.0.4",
25
+ "expo-system-ui": "~55.0.9",
26
+ "expo-web-browser": "~55.0.9",
1827 "nativewind": "^4",
1928 "react": "19.2.0",
29
+ "react-dom": "^19.2.4",
2030 "react-native": "0.83.2",
2131 "react-native-gesture-handler": "~2.30.0",
2232 "react-native-reanimated": "4.2.1",
2333 "react-native-safe-area-context": "~5.6.2",
2434 "react-native-screens": "~4.23.0",
25
- "react-native-svg": "15.15.3"
35
+ "react-native-svg": "15.15.3",
36
+ "react-native-web": "^0.21.0",
37
+ "react-native-worklets": "0.7.2"
2638 },
2739 "devDependencies": {
2840 "@types/react": "~19.2.2",
2941 "babel-plugin-module-resolver": "^5.0.2",
42
+ "babel-preset-expo": "^55.0.10",
3043 "tailwindcss": "^3.4.19",
3144 "typescript": "~5.9.2"
3245 },
services/audio.ts
....@@ -1,112 +1,65 @@
1
-import { Audio, AVPlaybackStatus } from "expo-av";
1
+import {
2
+ createAudioPlayer,
3
+ requestRecordingPermissionsAsync,
4
+ setAudioModeAsync,
5
+} from "expo-audio";
26
37 export interface RecordingResult {
48 uri: string;
59 durationMs: number;
610 }
711
8
-let currentRecording: Audio.Recording | null = null;
9
-let currentSound: Audio.Sound | null = null;
12
+let currentPlayer: ReturnType<typeof createAudioPlayer> | null = null;
1013
11
-async function requestPermissions(): Promise<boolean> {
12
- const { status } = await Audio.requestPermissionsAsync();
14
+export async function requestPermissions(): Promise<boolean> {
15
+ const { status } = await requestRecordingPermissionsAsync();
1316 return status === "granted";
14
-}
15
-
16
-export async function startRecording(): Promise<Audio.Recording | null> {
17
- const granted = await requestPermissions();
18
- if (!granted) return null;
19
-
20
- try {
21
- await Audio.setAudioModeAsync({
22
- allowsRecordingIOS: true,
23
- playsInSilentModeIOS: true,
24
- });
25
-
26
- const { recording } = await Audio.Recording.createAsync(
27
- Audio.RecordingOptionsPresets.HIGH_QUALITY
28
- );
29
-
30
- currentRecording = recording;
31
- return recording;
32
- } catch (error) {
33
- console.error("Failed to start recording:", error);
34
- return null;
35
- }
36
-}
37
-
38
-export async function stopRecording(): Promise<RecordingResult | null> {
39
- if (!currentRecording) return null;
40
-
41
- try {
42
- await currentRecording.stopAndUnloadAsync();
43
- const status = await currentRecording.getStatusAsync();
44
- const uri = currentRecording.getURI();
45
- currentRecording = null;
46
-
47
- await Audio.setAudioModeAsync({
48
- allowsRecordingIOS: false,
49
- });
50
-
51
- if (!uri) return null;
52
-
53
- const durationMs = (status as { durationMillis?: number }).durationMillis ?? 0;
54
- return { uri, durationMs };
55
- } catch (error) {
56
- console.error("Failed to stop recording:", error);
57
- currentRecording = null;
58
- return null;
59
- }
6017 }
6118
6219 export async function playAudio(
6320 uri: string,
6421 onFinish?: () => void
65
-): Promise<Audio.Sound | null> {
22
+): Promise<void> {
6623 try {
6724 await stopPlayback();
6825
69
- await Audio.setAudioModeAsync({
70
- allowsRecordingIOS: false,
71
- playsInSilentModeIOS: true,
26
+ await setAudioModeAsync({
27
+ playsInSilentMode: true,
7228 });
7329
74
- const { sound } = await Audio.Sound.createAsync(
75
- { uri },
76
- { shouldPlay: true }
77
- );
30
+ const player = createAudioPlayer(uri);
31
+ currentPlayer = player;
7832
79
- currentSound = sound;
80
-
81
- sound.setOnPlaybackStatusUpdate((status: AVPlaybackStatus) => {
82
- if (status.isLoaded && status.didJustFinish) {
33
+ player.addListener("playbackStatusUpdate", (status) => {
34
+ if (!status.playing && status.currentTime >= status.duration && status.duration > 0) {
8335 onFinish?.();
84
- sound.unloadAsync().catch(() => {});
85
- currentSound = null;
36
+ player.remove();
37
+ if (currentPlayer === player) currentPlayer = null;
8638 }
8739 });
8840
89
- return sound;
41
+ player.play();
9042 } catch (error) {
9143 console.error("Failed to play audio:", error);
92
- return null;
9344 }
9445 }
9546
9647 export async function stopPlayback(): Promise<void> {
97
- if (currentSound) {
48
+ if (currentPlayer) {
9849 try {
99
- await currentSound.stopAsync();
100
- await currentSound.unloadAsync();
50
+ currentPlayer.pause();
51
+ currentPlayer.remove();
10152 } catch {
102
- // Ignore errors during cleanup
53
+ // Ignore cleanup errors
10354 }
104
- currentSound = null;
55
+ currentPlayer = null;
10556 }
10657 }
10758
108
-export function encodeAudioToBase64(uri: string): Promise<string> {
109
- // In React Native, we'd use FileSystem from expo-file-system
110
- // For now, return the URI as-is since we may not have expo-file-system
111
- return Promise.resolve(uri);
59
+export async function encodeAudioToBase64(uri: string): Promise<string> {
60
+ const FileSystem = await import("expo-file-system");
61
+ const result = await FileSystem.readAsStringAsync(uri, {
62
+ encoding: FileSystem.EncodingType.Base64,
63
+ });
64
+ return result;
11265 }
types/index.ts
....@@ -1,5 +1,5 @@
11 export type MessageRole = "user" | "assistant" | "system";
2
-export type MessageType = "text" | "voice";
2
+export type MessageType = "text" | "voice" | "image";
33
44 export interface Message {
55 id: string;
....@@ -7,6 +7,7 @@
77 type: MessageType;
88 content: string;
99 audioUri?: string;
10
+ imageBase64?: string;
1011 timestamp: number;
1112 status?: "sending" | "sent" | "error";
1213 duration?: number;
....@@ -19,8 +20,81 @@
1920
2021 export type ConnectionStatus = "disconnected" | "connecting" | "connected";
2122
22
-export interface WebSocketMessage {
23
- type: "text" | "voice";
23
+// --- WebSocket protocol ---
24
+
25
+/** Outgoing from app to watcher */
26
+export interface WsTextMessage {
27
+ type: "text";
28
+ content: string;
29
+}
30
+
31
+export interface WsVoiceMessage {
32
+ type: "voice";
33
+ audioBase64: string;
34
+ content: string;
35
+}
36
+
37
+export interface WsCommandMessage {
38
+ type: "command";
39
+ command: string;
40
+ args?: Record<string, unknown>;
41
+}
42
+
43
+export type WsOutgoing = WsTextMessage | WsVoiceMessage | WsCommandMessage;
44
+
45
+/** Incoming from watcher to app */
46
+export interface WsIncomingText {
47
+ type: "text";
48
+ content: string;
49
+}
50
+
51
+export interface WsIncomingVoice {
52
+ type: "voice";
2453 content: string;
2554 audioBase64?: string;
2655 }
56
+
57
+export interface WsIncomingImage {
58
+ type: "image";
59
+ imageBase64: string;
60
+ caption?: string;
61
+}
62
+
63
+export interface WsSession {
64
+ index: number;
65
+ name: string;
66
+ type: "claude" | "terminal";
67
+ isActive: boolean;
68
+ id: string;
69
+}
70
+
71
+export interface WsIncomingSessions {
72
+ type: "sessions";
73
+ sessions: WsSession[];
74
+}
75
+
76
+export interface WsIncomingSessionSwitched {
77
+ type: "session_switched";
78
+ name: string;
79
+ sessionId: string;
80
+}
81
+
82
+export interface WsIncomingSessionRenamed {
83
+ type: "session_renamed";
84
+ sessionId: string;
85
+ name: string;
86
+}
87
+
88
+export interface WsIncomingError {
89
+ type: "error";
90
+ message: string;
91
+}
92
+
93
+export type WsIncoming =
94
+ | WsIncomingText
95
+ | WsIncomingVoice
96
+ | WsIncomingImage
97
+ | WsIncomingSessions
98
+ | WsIncomingSessionSwitched
99
+ | WsIncomingSessionRenamed
100
+ | WsIncomingError;