Matthias Nott
2026-03-07 af1543135d42adc2e97dc5243aeef7418cd3b00d
contexts/ChatContext.tsx
....@@ -8,23 +8,115 @@
88 } from "react";
99 import { Message, WsIncoming, WsSession } from "../types";
1010 import { useConnection } from "./ConnectionContext";
11
-import { playAudio, encodeAudioToBase64 } from "../services/audio";
11
+import { playAudio, encodeAudioToBase64, saveBase64Audio } from "../services/audio";
12
+import { requestNotificationPermissions, notifyIncomingMessage } from "../services/notifications";
1213
1314 function generateId(): string {
1415 return Date.now().toString(36) + Math.random().toString(36).slice(2);
1516 }
1617
18
+// --- Message persistence ---
19
+// Lazily import expo-file-system/legacy so a missing native module doesn't crash the app.
20
+
21
+let _fsReady: Promise<typeof import("expo-file-system/legacy")> | null = null;
22
+function getFs() {
23
+ if (!_fsReady) _fsReady = import("expo-file-system/legacy");
24
+ return _fsReady;
25
+}
26
+
27
+const MESSAGES_DIR = "pailot-messages";
28
+
29
+/** Strip heavy fields (base64 images, audio URIs) before persisting. */
30
+function lightMessage(m: Message): Message {
31
+ const light = { ...m };
32
+ if (light.imageBase64) light.imageBase64 = undefined;
33
+ if (light.audioUri) light.audioUri = undefined;
34
+ return light;
35
+}
36
+
37
+async function persistMessages(map: Record<string, Message[]>): Promise<void> {
38
+ try {
39
+ const fs = await getFs();
40
+ const dir = `${fs.documentDirectory}${MESSAGES_DIR}/`;
41
+ const dirInfo = await fs.getInfoAsync(dir);
42
+ if (!dirInfo.exists) await fs.makeDirectoryAsync(dir, { intermediates: true });
43
+ // Save each session's messages
44
+ for (const [sessionId, msgs] of Object.entries(map)) {
45
+ if (msgs.length === 0) continue;
46
+ const light = msgs.map(lightMessage);
47
+ await fs.writeAsStringAsync(`${dir}${sessionId}.json`, JSON.stringify(light));
48
+ }
49
+ } catch {
50
+ // Persistence is best-effort
51
+ }
52
+}
53
+
54
+async function loadMessages(): Promise<Record<string, Message[]>> {
55
+ try {
56
+ const fs = await getFs();
57
+ const dir = `${fs.documentDirectory}${MESSAGES_DIR}/`;
58
+ const dirInfo = await fs.getInfoAsync(dir);
59
+ if (!dirInfo.exists) return {};
60
+ const files = await fs.readDirectoryAsync(dir);
61
+ const result: Record<string, Message[]> = {};
62
+ for (const file of files) {
63
+ if (!file.endsWith(".json")) continue;
64
+ const sessionId = file.replace(".json", "");
65
+ const content = await fs.readAsStringAsync(`${dir}${file}`);
66
+ result[sessionId] = JSON.parse(content) as Message[];
67
+ }
68
+ return result;
69
+ } catch {
70
+ return {};
71
+ }
72
+}
73
+
74
+async function deletePersistedSession(sessionId: string): Promise<void> {
75
+ try {
76
+ const fs = await getFs();
77
+ const path = `${fs.documentDirectory}${MESSAGES_DIR}/${sessionId}.json`;
78
+ const info = await fs.getInfoAsync(path);
79
+ if (info.exists) await fs.deleteAsync(path);
80
+ } catch {
81
+ // Best-effort
82
+ }
83
+}
84
+
85
+async function clearPersistedMessages(sessionId: string): Promise<void> {
86
+ try {
87
+ const fs = await getFs();
88
+ await fs.writeAsStringAsync(
89
+ `${fs.documentDirectory}${MESSAGES_DIR}/${sessionId}.json`,
90
+ "[]"
91
+ );
92
+ } catch {
93
+ // Best-effort
94
+ }
95
+}
96
+
97
+// --- Debounced save ---
98
+let saveTimer: ReturnType<typeof setTimeout> | null = null;
99
+function debouncedSave(map: Record<string, Message[]>): void {
100
+ if (saveTimer) clearTimeout(saveTimer);
101
+ saveTimer = setTimeout(() => persistMessages(map), 1000);
102
+}
103
+
104
+// --- Context ---
105
+
17106 interface ChatContextValue {
18107 messages: Message[];
19108 sendTextMessage: (text: string) => void;
20109 sendVoiceMessage: (audioUri: string, durationMs?: number) => void;
110
+ sendImageMessage: (imageBase64: string, caption: string, mimeType: string) => void;
21111 clearMessages: () => void;
22
- // Session management
23112 sessions: WsSession[];
113
+ activeSessionId: string | null;
24114 requestSessions: () => void;
25115 switchSession: (sessionId: string) => void;
26116 renameSession: (sessionId: string, name: string) => void;
27
- // Screenshot / navigation
117
+ removeSession: (sessionId: string) => void;
118
+ createSession: () => void;
119
+ unreadCounts: Record<string, number>;
28120 latestScreenshot: string | null;
29121 requestScreenshot: () => void;
30122 sendNavKey: (key: string) => void;
....@@ -33,18 +125,104 @@
33125 const ChatContext = createContext<ChatContextValue | null>(null);
34126
35127 export function ChatProvider({ children }: { children: React.ReactNode }) {
36
- const [messages, setMessages] = useState<Message[]>([]);
37128 const [sessions, setSessions] = useState<WsSession[]>([]);
129
+ const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
38130 const [latestScreenshot, setLatestScreenshot] = useState<string | null>(null);
131
+ const needsSync = useRef(true);
132
+
133
+ // Per-session message storage
134
+ const messagesMapRef = useRef<Record<string, Message[]>>({});
135
+ // Messages for the active session (drives re-renders)
136
+ const [messages, setMessages] = useState<Message[]>([]);
137
+ // Unread counts for non-active sessions
138
+ const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
139
+
39140 const {
141
+ status,
40142 sendTextMessage: wsSend,
41143 sendVoiceMessage: wsVoice,
144
+ sendImageMessage: wsImageSend,
42145 sendCommand,
43146 onMessageReceived,
44147 } = useConnection();
45148
46
- const addMessage = useCallback((msg: Message) => {
47
- setMessages((prev) => [...prev, msg]);
149
+ // Restore persisted messages on mount + request notification permissions
150
+ useEffect(() => {
151
+ loadMessages().then((loaded) => {
152
+ if (Object.keys(loaded).length > 0) {
153
+ messagesMapRef.current = loaded;
154
+ }
155
+ });
156
+ requestNotificationPermissions();
157
+ }, []);
158
+
159
+ // Derive active session ID from sessions list when it arrives
160
+ const syncActiveFromSessions = useCallback((incoming: WsSession[]) => {
161
+ const active = incoming.find((s) => s.isActive);
162
+ if (active) {
163
+ setActiveSessionId((prev) => {
164
+ if (prev !== active.id) {
165
+ if (prev) {
166
+ messagesMapRef.current[prev] = messages;
167
+ }
168
+ const stored = messagesMapRef.current[active.id] ?? [];
169
+ setMessages(stored);
170
+ setUnreadCounts((u) => {
171
+ if (!u[active.id]) return u;
172
+ const next = { ...u };
173
+ delete next[active.id];
174
+ return next;
175
+ });
176
+ }
177
+ return active.id;
178
+ });
179
+ }
180
+ }, [messages]);
181
+
182
+ // On connect: ask gateway to detect the focused iTerm2 session and sync
183
+ useEffect(() => {
184
+ if (status === "connected") {
185
+ needsSync.current = true;
186
+ sendCommand("sync");
187
+ }
188
+ }, [status, sendCommand]);
189
+
190
+ // Helper: add a message to the active session
191
+ const addMessageToActive = useCallback((msg: Message) => {
192
+ setMessages((prev) => {
193
+ const next = [...prev, msg];
194
+ setActiveSessionId((id) => {
195
+ if (id) {
196
+ messagesMapRef.current[id] = next;
197
+ debouncedSave(messagesMapRef.current);
198
+ }
199
+ return id;
200
+ });
201
+ return next;
202
+ });
203
+ }, []);
204
+
205
+ // Helper: add a message to a specific session (may not be active)
206
+ const addMessageToSession = useCallback((sessionId: string, msg: Message) => {
207
+ setActiveSessionId((currentActive) => {
208
+ if (sessionId === currentActive) {
209
+ setMessages((prev) => {
210
+ const next = [...prev, msg];
211
+ messagesMapRef.current[sessionId] = next;
212
+ debouncedSave(messagesMapRef.current);
213
+ return next;
214
+ });
215
+ } else {
216
+ const existing = messagesMapRef.current[sessionId] ?? [];
217
+ messagesMapRef.current[sessionId] = [...existing, msg];
218
+ debouncedSave(messagesMapRef.current);
219
+ setUnreadCounts((u) => ({
220
+ ...u,
221
+ [sessionId]: (u[sessionId] ?? 0) + 1,
222
+ }));
223
+ }
224
+ return currentActive;
225
+ });
48226 }, []);
49227
50228 const updateMessageStatus = useCallback(
....@@ -58,7 +236,7 @@
58236
59237 // Handle incoming WebSocket messages
60238 useEffect(() => {
61
- onMessageReceived.current = (data: WsIncoming) => {
239
+ onMessageReceived.current = async (data: WsIncoming) => {
62240 switch (data.type) {
63241 case "text": {
64242 const msg: Message = {
....@@ -69,31 +247,37 @@
69247 timestamp: Date.now(),
70248 status: "sent",
71249 };
72
- setMessages((prev) => [...prev, msg]);
250
+ addMessageToActive(msg);
251
+ notifyIncomingMessage("PAILot", data.content ?? "New message");
73252 break;
74253 }
75254 case "voice": {
255
+ let audioUri: string | undefined;
256
+ if (data.audioBase64) {
257
+ try {
258
+ audioUri = await saveBase64Audio(data.audioBase64);
259
+ } catch {
260
+ // fallback: no playable audio
261
+ }
262
+ }
76263 const msg: Message = {
77264 id: generateId(),
78265 role: "assistant",
79266 type: "voice",
80267 content: data.content ?? "",
81
- audioUri: data.audioBase64
82
- ? `data:audio/mp4;base64,${data.audioBase64}`
83
- : undefined,
268
+ audioUri,
84269 timestamp: Date.now(),
85270 status: "sent",
86271 };
87
- setMessages((prev) => [...prev, msg]);
272
+ addMessageToActive(msg);
273
+ notifyIncomingMessage("PAILot", data.content ?? "Voice message");
88274 if (msg.audioUri) {
89275 playAudio(msg.audioUri).catch(() => {});
90276 }
91277 break;
92278 }
93279 case "image": {
94
- // Store as latest screenshot for navigation mode
95280 setLatestScreenshot(data.imageBase64);
96
- // Also add to chat as an image message
97281 const msg: Message = {
98282 id: generateId(),
99283 role: "assistant",
....@@ -103,11 +287,15 @@
103287 timestamp: Date.now(),
104288 status: "sent",
105289 };
106
- setMessages((prev) => [...prev, msg]);
290
+ addMessageToActive(msg);
291
+ notifyIncomingMessage("PAILot", data.caption ?? "New image");
107292 break;
108293 }
109294 case "sessions": {
110
- setSessions(data.sessions);
295
+ const incoming = data.sessions as WsSession[];
296
+ setSessions(incoming);
297
+ syncActiveFromSessions(incoming);
298
+ needsSync.current = false;
111299 break;
112300 }
113301 case "session_switched": {
....@@ -118,7 +306,8 @@
118306 content: `Switched to ${data.name}`,
119307 timestamp: Date.now(),
120308 };
121
- setMessages((prev) => [...prev, msg]);
309
+ addMessageToActive(msg);
310
+ sendCommand("sessions");
122311 break;
123312 }
124313 case "session_renamed": {
....@@ -129,8 +318,7 @@
129318 content: `Renamed to ${data.name}`,
130319 timestamp: Date.now(),
131320 };
132
- setMessages((prev) => [...prev, msg]);
133
- // Refresh sessions to show updated name
321
+ addMessageToActive(msg);
134322 sendCommand("sessions");
135323 break;
136324 }
....@@ -142,7 +330,7 @@
142330 content: data.message,
143331 timestamp: Date.now(),
144332 };
145
- setMessages((prev) => [...prev, msg]);
333
+ addMessageToActive(msg);
146334 break;
147335 }
148336 }
....@@ -151,7 +339,7 @@
151339 return () => {
152340 onMessageReceived.current = null;
153341 };
154
- }, [onMessageReceived, sendCommand]);
342
+ }, [onMessageReceived, sendCommand, addMessageToActive, syncActiveFromSessions]);
155343
156344 const sendTextMessage = useCallback(
157345 (text: string) => {
....@@ -164,11 +352,11 @@
164352 timestamp: Date.now(),
165353 status: "sending",
166354 };
167
- addMessage(msg);
355
+ addMessageToActive(msg);
168356 const sent = wsSend(text);
169357 updateMessageStatus(id, sent ? "sent" : "error");
170358 },
171
- [wsSend, addMessage, updateMessageStatus]
359
+ [wsSend, addMessageToActive, updateMessageStatus]
172360 );
173361
174362 const sendVoiceMessage = useCallback(
....@@ -184,7 +372,7 @@
184372 status: "sending",
185373 duration: durationMs,
186374 };
187
- addMessage(msg);
375
+ addMessageToActive(msg);
188376 try {
189377 const base64 = await encodeAudioToBase64(audioUri);
190378 const sent = wsVoice(base64);
....@@ -194,11 +382,37 @@
194382 updateMessageStatus(id, "error");
195383 }
196384 },
197
- [wsVoice, addMessage, updateMessageStatus]
385
+ [wsVoice, addMessageToActive, updateMessageStatus]
386
+ );
387
+
388
+ const sendImageMessage = useCallback(
389
+ (imageBase64: string, caption: string, mimeType: string) => {
390
+ const id = generateId();
391
+ const msg: Message = {
392
+ id,
393
+ role: "user",
394
+ type: "image",
395
+ content: caption || "Photo",
396
+ imageBase64,
397
+ timestamp: Date.now(),
398
+ status: "sending",
399
+ };
400
+ addMessageToActive(msg);
401
+ const sent = wsImageSend(imageBase64, caption, mimeType);
402
+ updateMessageStatus(id, sent ? "sent" : "error");
403
+ },
404
+ [wsImageSend, addMessageToActive, updateMessageStatus]
198405 );
199406
200407 const clearMessages = useCallback(() => {
201408 setMessages([]);
409
+ setActiveSessionId((id) => {
410
+ if (id) {
411
+ messagesMapRef.current[id] = [];
412
+ clearPersistedMessages(id);
413
+ }
414
+ return id;
415
+ });
202416 }, []);
203417
204418 // --- Session management ---
....@@ -208,9 +422,16 @@
208422
209423 const switchSession = useCallback(
210424 (sessionId: string) => {
425
+ setActiveSessionId((prev) => {
426
+ if (prev) {
427
+ messagesMapRef.current[prev] = messages;
428
+ debouncedSave(messagesMapRef.current);
429
+ }
430
+ return prev;
431
+ });
211432 sendCommand("switch", { sessionId });
212433 },
213
- [sendCommand]
434
+ [sendCommand, messages]
214435 );
215436
216437 const renameSession = useCallback(
....@@ -219,6 +440,25 @@
219440 },
220441 [sendCommand]
221442 );
443
+
444
+ const removeSession = useCallback(
445
+ (sessionId: string) => {
446
+ sendCommand("remove", { sessionId });
447
+ delete messagesMapRef.current[sessionId];
448
+ deletePersistedSession(sessionId);
449
+ setUnreadCounts((u) => {
450
+ if (!u[sessionId]) return u;
451
+ const next = { ...u };
452
+ delete next[sessionId];
453
+ return next;
454
+ });
455
+ },
456
+ [sendCommand]
457
+ );
458
+
459
+ const createSession = useCallback(() => {
460
+ sendCommand("create");
461
+ }, [sendCommand]);
222462
223463 // --- Screenshot / navigation ---
224464 const requestScreenshot = useCallback(() => {
....@@ -238,11 +478,16 @@
238478 messages,
239479 sendTextMessage,
240480 sendVoiceMessage,
481
+ sendImageMessage,
241482 clearMessages,
242483 sessions,
484
+ activeSessionId,
243485 requestSessions,
244486 switchSession,
245487 renameSession,
488
+ removeSession,
489
+ createSession,
490
+ unreadCounts,
246491 latestScreenshot,
247492 requestScreenshot,
248493 sendNavKey,