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