From af1543135d42adc2e97dc5243aeef7418cd3b00d Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sat, 07 Mar 2026 08:39:26 +0100
Subject: [PATCH] feat: dual address auto-switch, custom icon, notifications, image support
---
contexts/ChatContext.tsx | 297 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
1 files changed, 271 insertions(+), 26 deletions(-)
diff --git a/contexts/ChatContext.tsx b/contexts/ChatContext.tsx
index a0b62fc..0865375 100644
--- a/contexts/ChatContext.tsx
+++ b/contexts/ChatContext.tsx
@@ -8,23 +8,115 @@
} from "react";
import { Message, WsIncoming, WsSession } from "../types";
import { useConnection } from "./ConnectionContext";
-import { playAudio, encodeAudioToBase64 } from "../services/audio";
+import { playAudio, encodeAudioToBase64, saveBase64Audio } from "../services/audio";
+import { requestNotificationPermissions, notifyIncomingMessage } from "../services/notifications";
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2);
}
+// --- Message persistence ---
+// Lazily import expo-file-system/legacy so a missing native module doesn't crash the app.
+
+let _fsReady: Promise<typeof import("expo-file-system/legacy")> | null = null;
+function getFs() {
+ if (!_fsReady) _fsReady = import("expo-file-system/legacy");
+ return _fsReady;
+}
+
+const MESSAGES_DIR = "pailot-messages";
+
+/** Strip heavy fields (base64 images, audio URIs) before persisting. */
+function lightMessage(m: Message): Message {
+ const light = { ...m };
+ if (light.imageBase64) light.imageBase64 = undefined;
+ if (light.audioUri) light.audioUri = undefined;
+ return light;
+}
+
+async function persistMessages(map: Record<string, Message[]>): Promise<void> {
+ try {
+ const fs = await getFs();
+ const dir = `${fs.documentDirectory}${MESSAGES_DIR}/`;
+ const dirInfo = await fs.getInfoAsync(dir);
+ if (!dirInfo.exists) await fs.makeDirectoryAsync(dir, { intermediates: true });
+ // Save each session's messages
+ for (const [sessionId, msgs] of Object.entries(map)) {
+ if (msgs.length === 0) continue;
+ const light = msgs.map(lightMessage);
+ await fs.writeAsStringAsync(`${dir}${sessionId}.json`, JSON.stringify(light));
+ }
+ } catch {
+ // Persistence is best-effort
+ }
+}
+
+async function loadMessages(): Promise<Record<string, Message[]>> {
+ try {
+ const fs = await getFs();
+ const dir = `${fs.documentDirectory}${MESSAGES_DIR}/`;
+ const dirInfo = await fs.getInfoAsync(dir);
+ if (!dirInfo.exists) return {};
+ const files = await fs.readDirectoryAsync(dir);
+ const result: Record<string, Message[]> = {};
+ for (const file of files) {
+ if (!file.endsWith(".json")) continue;
+ const sessionId = file.replace(".json", "");
+ const content = await fs.readAsStringAsync(`${dir}${file}`);
+ result[sessionId] = JSON.parse(content) as Message[];
+ }
+ return result;
+ } catch {
+ return {};
+ }
+}
+
+async function deletePersistedSession(sessionId: string): Promise<void> {
+ try {
+ const fs = await getFs();
+ const path = `${fs.documentDirectory}${MESSAGES_DIR}/${sessionId}.json`;
+ const info = await fs.getInfoAsync(path);
+ if (info.exists) await fs.deleteAsync(path);
+ } catch {
+ // Best-effort
+ }
+}
+
+async function clearPersistedMessages(sessionId: string): Promise<void> {
+ try {
+ const fs = await getFs();
+ await fs.writeAsStringAsync(
+ `${fs.documentDirectory}${MESSAGES_DIR}/${sessionId}.json`,
+ "[]"
+ );
+ } catch {
+ // Best-effort
+ }
+}
+
+// --- Debounced save ---
+let saveTimer: ReturnType<typeof setTimeout> | null = null;
+function debouncedSave(map: Record<string, Message[]>): void {
+ if (saveTimer) clearTimeout(saveTimer);
+ saveTimer = setTimeout(() => persistMessages(map), 1000);
+}
+
+// --- Context ---
+
interface ChatContextValue {
messages: Message[];
sendTextMessage: (text: string) => void;
sendVoiceMessage: (audioUri: string, durationMs?: number) => void;
+ sendImageMessage: (imageBase64: string, caption: string, mimeType: string) => void;
clearMessages: () => void;
- // Session management
sessions: WsSession[];
+ activeSessionId: string | null;
requestSessions: () => void;
switchSession: (sessionId: string) => void;
renameSession: (sessionId: string, name: string) => void;
- // Screenshot / navigation
+ removeSession: (sessionId: string) => void;
+ createSession: () => void;
+ unreadCounts: Record<string, number>;
latestScreenshot: string | null;
requestScreenshot: () => void;
sendNavKey: (key: string) => void;
@@ -33,18 +125,104 @@
const ChatContext = createContext<ChatContextValue | null>(null);
export function ChatProvider({ children }: { children: React.ReactNode }) {
- const [messages, setMessages] = useState<Message[]>([]);
const [sessions, setSessions] = useState<WsSession[]>([]);
+ const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [latestScreenshot, setLatestScreenshot] = useState<string | null>(null);
+ const needsSync = useRef(true);
+
+ // Per-session message storage
+ const messagesMapRef = useRef<Record<string, Message[]>>({});
+ // Messages for the active session (drives re-renders)
+ const [messages, setMessages] = useState<Message[]>([]);
+ // Unread counts for non-active sessions
+ const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
+
const {
+ status,
sendTextMessage: wsSend,
sendVoiceMessage: wsVoice,
+ sendImageMessage: wsImageSend,
sendCommand,
onMessageReceived,
} = useConnection();
- const addMessage = useCallback((msg: Message) => {
- setMessages((prev) => [...prev, msg]);
+ // Restore persisted messages on mount + request notification permissions
+ useEffect(() => {
+ loadMessages().then((loaded) => {
+ if (Object.keys(loaded).length > 0) {
+ messagesMapRef.current = loaded;
+ }
+ });
+ requestNotificationPermissions();
+ }, []);
+
+ // Derive active session ID from sessions list when it arrives
+ const syncActiveFromSessions = useCallback((incoming: WsSession[]) => {
+ const active = incoming.find((s) => s.isActive);
+ if (active) {
+ setActiveSessionId((prev) => {
+ if (prev !== active.id) {
+ if (prev) {
+ messagesMapRef.current[prev] = messages;
+ }
+ const stored = messagesMapRef.current[active.id] ?? [];
+ setMessages(stored);
+ setUnreadCounts((u) => {
+ if (!u[active.id]) return u;
+ const next = { ...u };
+ delete next[active.id];
+ return next;
+ });
+ }
+ return active.id;
+ });
+ }
+ }, [messages]);
+
+ // On connect: ask gateway to detect the focused iTerm2 session and sync
+ useEffect(() => {
+ if (status === "connected") {
+ needsSync.current = true;
+ sendCommand("sync");
+ }
+ }, [status, sendCommand]);
+
+ // Helper: add a message to the active session
+ const addMessageToActive = useCallback((msg: Message) => {
+ setMessages((prev) => {
+ const next = [...prev, msg];
+ setActiveSessionId((id) => {
+ if (id) {
+ messagesMapRef.current[id] = next;
+ debouncedSave(messagesMapRef.current);
+ }
+ return id;
+ });
+ return next;
+ });
+ }, []);
+
+ // Helper: add a message to a specific session (may not be active)
+ const addMessageToSession = useCallback((sessionId: string, msg: Message) => {
+ setActiveSessionId((currentActive) => {
+ if (sessionId === currentActive) {
+ setMessages((prev) => {
+ const next = [...prev, msg];
+ messagesMapRef.current[sessionId] = next;
+ debouncedSave(messagesMapRef.current);
+ return next;
+ });
+ } else {
+ const existing = messagesMapRef.current[sessionId] ?? [];
+ messagesMapRef.current[sessionId] = [...existing, msg];
+ debouncedSave(messagesMapRef.current);
+ setUnreadCounts((u) => ({
+ ...u,
+ [sessionId]: (u[sessionId] ?? 0) + 1,
+ }));
+ }
+ return currentActive;
+ });
}, []);
const updateMessageStatus = useCallback(
@@ -58,7 +236,7 @@
// Handle incoming WebSocket messages
useEffect(() => {
- onMessageReceived.current = (data: WsIncoming) => {
+ onMessageReceived.current = async (data: WsIncoming) => {
switch (data.type) {
case "text": {
const msg: Message = {
@@ -69,31 +247,37 @@
timestamp: Date.now(),
status: "sent",
};
- setMessages((prev) => [...prev, msg]);
+ addMessageToActive(msg);
+ notifyIncomingMessage("PAILot", data.content ?? "New message");
break;
}
case "voice": {
+ let audioUri: string | undefined;
+ if (data.audioBase64) {
+ try {
+ audioUri = await saveBase64Audio(data.audioBase64);
+ } catch {
+ // fallback: no playable audio
+ }
+ }
const msg: Message = {
id: generateId(),
role: "assistant",
type: "voice",
content: data.content ?? "",
- audioUri: data.audioBase64
- ? `data:audio/mp4;base64,${data.audioBase64}`
- : undefined,
+ audioUri,
timestamp: Date.now(),
status: "sent",
};
- setMessages((prev) => [...prev, msg]);
+ addMessageToActive(msg);
+ notifyIncomingMessage("PAILot", data.content ?? "Voice message");
if (msg.audioUri) {
playAudio(msg.audioUri).catch(() => {});
}
break;
}
case "image": {
- // Store as latest screenshot for navigation mode
setLatestScreenshot(data.imageBase64);
- // Also add to chat as an image message
const msg: Message = {
id: generateId(),
role: "assistant",
@@ -103,11 +287,15 @@
timestamp: Date.now(),
status: "sent",
};
- setMessages((prev) => [...prev, msg]);
+ addMessageToActive(msg);
+ notifyIncomingMessage("PAILot", data.caption ?? "New image");
break;
}
case "sessions": {
- setSessions(data.sessions);
+ const incoming = data.sessions as WsSession[];
+ setSessions(incoming);
+ syncActiveFromSessions(incoming);
+ needsSync.current = false;
break;
}
case "session_switched": {
@@ -118,7 +306,8 @@
content: `Switched to ${data.name}`,
timestamp: Date.now(),
};
- setMessages((prev) => [...prev, msg]);
+ addMessageToActive(msg);
+ sendCommand("sessions");
break;
}
case "session_renamed": {
@@ -129,8 +318,7 @@
content: `Renamed to ${data.name}`,
timestamp: Date.now(),
};
- setMessages((prev) => [...prev, msg]);
- // Refresh sessions to show updated name
+ addMessageToActive(msg);
sendCommand("sessions");
break;
}
@@ -142,7 +330,7 @@
content: data.message,
timestamp: Date.now(),
};
- setMessages((prev) => [...prev, msg]);
+ addMessageToActive(msg);
break;
}
}
@@ -151,7 +339,7 @@
return () => {
onMessageReceived.current = null;
};
- }, [onMessageReceived, sendCommand]);
+ }, [onMessageReceived, sendCommand, addMessageToActive, syncActiveFromSessions]);
const sendTextMessage = useCallback(
(text: string) => {
@@ -164,11 +352,11 @@
timestamp: Date.now(),
status: "sending",
};
- addMessage(msg);
+ addMessageToActive(msg);
const sent = wsSend(text);
updateMessageStatus(id, sent ? "sent" : "error");
},
- [wsSend, addMessage, updateMessageStatus]
+ [wsSend, addMessageToActive, updateMessageStatus]
);
const sendVoiceMessage = useCallback(
@@ -184,7 +372,7 @@
status: "sending",
duration: durationMs,
};
- addMessage(msg);
+ addMessageToActive(msg);
try {
const base64 = await encodeAudioToBase64(audioUri);
const sent = wsVoice(base64);
@@ -194,11 +382,37 @@
updateMessageStatus(id, "error");
}
},
- [wsVoice, addMessage, updateMessageStatus]
+ [wsVoice, addMessageToActive, updateMessageStatus]
+ );
+
+ const sendImageMessage = useCallback(
+ (imageBase64: string, caption: string, mimeType: string) => {
+ const id = generateId();
+ const msg: Message = {
+ id,
+ role: "user",
+ type: "image",
+ content: caption || "Photo",
+ imageBase64,
+ timestamp: Date.now(),
+ status: "sending",
+ };
+ addMessageToActive(msg);
+ const sent = wsImageSend(imageBase64, caption, mimeType);
+ updateMessageStatus(id, sent ? "sent" : "error");
+ },
+ [wsImageSend, addMessageToActive, updateMessageStatus]
);
const clearMessages = useCallback(() => {
setMessages([]);
+ setActiveSessionId((id) => {
+ if (id) {
+ messagesMapRef.current[id] = [];
+ clearPersistedMessages(id);
+ }
+ return id;
+ });
}, []);
// --- Session management ---
@@ -208,9 +422,16 @@
const switchSession = useCallback(
(sessionId: string) => {
+ setActiveSessionId((prev) => {
+ if (prev) {
+ messagesMapRef.current[prev] = messages;
+ debouncedSave(messagesMapRef.current);
+ }
+ return prev;
+ });
sendCommand("switch", { sessionId });
},
- [sendCommand]
+ [sendCommand, messages]
);
const renameSession = useCallback(
@@ -219,6 +440,25 @@
},
[sendCommand]
);
+
+ const removeSession = useCallback(
+ (sessionId: string) => {
+ sendCommand("remove", { sessionId });
+ delete messagesMapRef.current[sessionId];
+ deletePersistedSession(sessionId);
+ setUnreadCounts((u) => {
+ if (!u[sessionId]) return u;
+ const next = { ...u };
+ delete next[sessionId];
+ return next;
+ });
+ },
+ [sendCommand]
+ );
+
+ const createSession = useCallback(() => {
+ sendCommand("create");
+ }, [sendCommand]);
// --- Screenshot / navigation ---
const requestScreenshot = useCallback(() => {
@@ -238,11 +478,16 @@
messages,
sendTextMessage,
sendVoiceMessage,
+ sendImageMessage,
clearMessages,
sessions,
+ activeSessionId,
requestSessions,
switchSession,
renameSession,
+ removeSession,
+ createSession,
+ unreadCounts,
latestScreenshot,
requestScreenshot,
sendNavKey,
--
Gitblit v1.3.1