From 4c266155785aad5050ebff7211e3d5f9e15c3238 Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sun, 08 Mar 2026 07:37:45 +0100
Subject: [PATCH] feat: explicit session addressing + toast queue + solid toast styling

---
 types/index.ts                  |    4 ++
 contexts/ConnectionContext.tsx  |   23 ++++++-----
 contexts/ChatContext.tsx        |   23 ++++++++---
 components/ui/IncomingToast.tsx |   51 +++++++++++++------------
 4 files changed, 58 insertions(+), 43 deletions(-)

diff --git a/components/ui/IncomingToast.tsx b/components/ui/IncomingToast.tsx
index 779c82e..2c9fb23 100644
--- a/components/ui/IncomingToast.tsx
+++ b/components/ui/IncomingToast.tsx
@@ -13,22 +13,24 @@
 
 export function IncomingToast({ sessionName, preview, onTap, onDismiss }: IncomingToastProps) {
   const { colors } = useTheme();
-  const opacity = useRef(new Animated.Value(0)).current;
-  const translateY = useRef(new Animated.Value(-40)).current;
+  const translateY = useRef(new Animated.Value(-60)).current;
 
   useEffect(() => {
-    // Slide in
-    Animated.parallel([
-      Animated.timing(opacity, { toValue: 1, duration: 200, useNativeDriver: true }),
-      Animated.timing(translateY, { toValue: 0, duration: 200, useNativeDriver: true }),
-    ]).start();
+    // Slide in from above (no opacity — keeps background solid)
+    Animated.spring(translateY, {
+      toValue: 0,
+      useNativeDriver: true,
+      tension: 80,
+      friction: 10,
+    }).start();
 
     // Auto-dismiss
     const timer = setTimeout(() => {
-      Animated.parallel([
-        Animated.timing(opacity, { toValue: 0, duration: 200, useNativeDriver: true }),
-        Animated.timing(translateY, { toValue: -40, duration: 200, useNativeDriver: true }),
-      ]).start(() => onDismiss());
+      Animated.timing(translateY, {
+        toValue: -60,
+        duration: 200,
+        useNativeDriver: true,
+      }).start(() => onDismiss());
     }, DISPLAY_MS);
 
     return () => clearTimeout(timer);
@@ -38,11 +40,10 @@
     <Animated.View
       style={{
         position: "absolute",
-        top: 0,
+        top: 4,
         left: 12,
         right: 12,
         zIndex: 100,
-        opacity,
         transform: [{ translateY }],
       }}
     >
@@ -57,31 +58,31 @@
           borderRadius: 12,
           backgroundColor: pressed ? colors.bgTertiary : colors.bgSecondary,
           borderWidth: 1,
-          borderColor: colors.border,
+          borderColor: colors.accent,
           shadowColor: "#000",
-          shadowOffset: { width: 0, height: 2 },
-          shadowOpacity: 0.25,
-          shadowRadius: 4,
-          elevation: 5,
+          shadowOffset: { width: 0, height: 4 },
+          shadowOpacity: 0.4,
+          shadowRadius: 8,
+          elevation: 8,
         })}
       >
         <View
           style={{
-            width: 8,
-            height: 8,
-            borderRadius: 4,
-            backgroundColor: "#3b82f6",
+            width: 10,
+            height: 10,
+            borderRadius: 5,
+            backgroundColor: colors.accent,
           }}
         />
         <View style={{ flex: 1 }}>
-          <Text style={{ color: colors.text, fontSize: 13, fontWeight: "700" }} numberOfLines={1}>
+          <Text style={{ color: colors.text, fontSize: 14, fontWeight: "700" }} numberOfLines={1}>
             {sessionName}
           </Text>
-          <Text style={{ color: colors.textMuted, fontSize: 12, marginTop: 1 }} numberOfLines={1}>
+          <Text style={{ color: colors.textMuted, fontSize: 12, marginTop: 2 }} numberOfLines={1}>
             {preview}
           </Text>
         </View>
-        <Text style={{ color: colors.textMuted, fontSize: 11 }}>tap to switch</Text>
+        <Text style={{ color: colors.accent, fontSize: 11, fontWeight: "600" }}>switch</Text>
       </Pressable>
     </Animated.View>
   );
diff --git a/contexts/ChatContext.tsx b/contexts/ChatContext.tsx
index b109bb7..144f375 100644
--- a/contexts/ChatContext.tsx
+++ b/contexts/ChatContext.tsx
@@ -167,7 +167,8 @@
   // Per-session typing indicator (sessionId → boolean)
   const typingMapRef = useRef<Record<string, boolean>>({});
   const [isTyping, setIsTyping] = useState(false);
-  // Toast for other-session incoming messages
+  // Toast queue for other-session incoming messages (show one at a time)
+  const toastQueueRef = useRef<{ sessionId: string; sessionName: string; preview: string }[]>([]);
   const [incomingToast, setIncomingToast] = useState<{ sessionId: string; sessionName: string; preview: string } | null>(null);
   // PAI projects list
   const [projects, setProjects] = useState<PaiProject[]>([]);
@@ -265,13 +266,19 @@
         ...u,
         [sessionId]: (u[sessionId] ?? 0) + 1,
       }));
-      // Show toast for other-session messages (assistant only, skip system noise)
+      // Queue toast for other-session messages (assistant only, skip system noise)
       if (msg.role === "assistant") {
         setSessions((prev) => {
           const session = prev.find((s) => s.id === sessionId);
           const name = session?.name ?? sessionId.slice(0, 8);
           const preview = msg.type === "voice" ? "🎤 Voice note" : msg.type === "image" ? "📷 Image" : (msg.content ?? "").slice(0, 60);
-          setIncomingToast({ sessionId, sessionName: name, preview });
+          const toast = { sessionId, sessionName: name, preview };
+          // If no toast is showing, show immediately; otherwise queue
+          setIncomingToast((current) => {
+            if (current === null) return toast;
+            toastQueueRef.current.push(toast);
+            return current;
+          });
           return prev;
         });
       }
@@ -444,7 +451,7 @@
         status: "sending",
       };
       addMessageToActive(msg);
-      const sent = wsSend(text);
+      const sent = wsSend(text, activeSessionIdRef.current ?? undefined);
       updateMessageStatus(id, sent ? "sent" : "error");
     },
     [wsSend, addMessageToActive, updateMessageStatus]
@@ -466,7 +473,7 @@
       addMessageToActive(msg);
       try {
         const base64 = await encodeAudioToBase64(audioUri);
-        const sent = wsVoice(base64, "", id);
+        const sent = wsVoice(base64, "", id, activeSessionIdRef.current ?? undefined);
         updateMessageStatus(id, sent ? "sent" : "error");
       } catch (err) {
         console.error("Failed to encode audio:", err);
@@ -489,7 +496,7 @@
         status: "sending",
       };
       addMessageToActive(msg);
-      const sent = wsImageSend(imageBase64, caption, mimeType);
+      const sent = wsImageSend(imageBase64, caption, mimeType, activeSessionIdRef.current ?? undefined);
       updateMessageStatus(id, sent ? "sent" : "error");
     },
     [wsImageSend, addMessageToActive, updateMessageStatus]
@@ -560,7 +567,9 @@
   }, [sendCommand]);
 
   const dismissToast = useCallback(() => {
-    setIncomingToast(null);
+    // Show next queued toast, or clear
+    const next = toastQueueRef.current.shift();
+    setIncomingToast(next ?? null);
   }, []);
 
   const loadMoreMessages = useCallback(() => {
diff --git a/contexts/ConnectionContext.tsx b/contexts/ConnectionContext.tsx
index f1fab69..ed245ba 100644
--- a/contexts/ConnectionContext.tsx
+++ b/contexts/ConnectionContext.tsx
@@ -23,10 +23,10 @@
   status: ConnectionStatus;
   connect: (config?: ServerConfig) => void;
   disconnect: () => void;
-  sendTextMessage: (text: string) => boolean;
-  sendVoiceMessage: (audioBase64: string, transcript?: string, messageId?: string) => boolean;
-  sendImageMessage: (imageBase64: string, caption: string, mimeType: string) => boolean;
-  sendCommand: (command: string, args?: Record<string, unknown>) => boolean;
+  sendTextMessage: (text: string, sessionId?: string) => boolean;
+  sendVoiceMessage: (audioBase64: string, transcript?: string, messageId?: string, sessionId?: string) => boolean;
+  sendImageMessage: (imageBase64: string, caption: string, mimeType: string, sessionId?: string) => boolean;
+  sendCommand: (command: string, args?: Record<string, unknown>, sessionId?: string) => boolean;
   saveServerConfig: (config: ServerConfig) => Promise<void>;
   onMessageReceived: React.MutableRefObject<
     ((data: WsIncoming) => void) | null
@@ -116,32 +116,33 @@
     setServerConfig(config);
   }, []);
 
-  const sendTextMessage = useCallback((text: string): boolean => {
-    return wsClient.send({ type: "text", content: text });
+  const sendTextMessage = useCallback((text: string, sessionId?: string): boolean => {
+    return wsClient.send({ type: "text", content: text, sessionId });
   }, []);
 
   const sendVoiceMessage = useCallback(
-    (audioBase64: string, transcript: string = "", messageId?: string): boolean => {
+    (audioBase64: string, transcript: string = "", messageId?: string, sessionId?: string): boolean => {
       return wsClient.send({
         type: "voice",
         content: transcript,
         audioBase64,
         messageId,
+        sessionId,
       });
     },
     []
   );
 
   const sendImageMessage = useCallback(
-    (imageBase64: string, caption: string = "", mimeType: string = "image/jpeg"): boolean => {
-      return wsClient.send({ type: "image", imageBase64, caption, mimeType });
+    (imageBase64: string, caption: string = "", mimeType: string = "image/jpeg", sessionId?: string): boolean => {
+      return wsClient.send({ type: "image", imageBase64, caption, mimeType, sessionId });
     },
     []
   );
 
   const sendCommand = useCallback(
-    (command: string, args?: Record<string, unknown>): boolean => {
-      const msg: WsOutgoing = { type: "command", command, args };
+    (command: string, args?: Record<string, unknown>, sessionId?: string): boolean => {
+      const msg: WsOutgoing = { type: "command", command, args, sessionId };
       return wsClient.send(msg as any);
     },
     []
diff --git a/types/index.ts b/types/index.ts
index 613d2d6..259915f 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -28,6 +28,7 @@
 export interface WsTextMessage {
   type: "text";
   content: string;
+  sessionId?: string;
 }
 
 export interface WsVoiceMessage {
@@ -35,6 +36,7 @@
   audioBase64: string;
   content: string;
   messageId?: string;
+  sessionId?: string;
 }
 
 export interface WsImageMessage {
@@ -42,12 +44,14 @@
   imageBase64: string;
   caption: string;
   mimeType: string;
+  sessionId?: string;
 }
 
 export interface WsCommandMessage {
   type: "command";
   command: string;
   args?: Record<string, unknown>;
+  sessionId?: string;
 }
 
 export type WsOutgoing = WsTextMessage | WsVoiceMessage | WsImageMessage | WsCommandMessage;

--
Gitblit v1.3.1