From d832f656599b153be8826bc43e1832209b2a1bf6 Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sun, 08 Mar 2026 07:10:12 +0100
Subject: [PATCH] feat: per-session typing, incoming toast, remove switched-to messages

---
 types/index.ts                  |    1 
 app/chat.tsx                    |   21 ++++++
 contexts/ChatContext.tsx        |   56 ++++++++++++------
 components/ui/IncomingToast.tsx |   88 +++++++++++++++++++++++++++++
 4 files changed, 146 insertions(+), 20 deletions(-)

diff --git a/app/chat.tsx b/app/chat.tsx
index bbc5a4c..2a177cc 100644
--- a/app/chat.tsx
+++ b/app/chat.tsx
@@ -10,6 +10,7 @@
 import { CommandBar, TextModeCommandBar } from "../components/chat/CommandBar";
 import { ImageCaptionModal } from "../components/chat/ImageCaptionModal";
 import { StatusDot } from "../components/ui/StatusDot";
+import { IncomingToast } from "../components/ui/IncomingToast";
 import { SessionDrawer } from "../components/SessionDrawer";
 import { playAudio, stopPlayback, isPlaying, onPlayingChange } from "../services/audio";
 
@@ -20,7 +21,7 @@
 }
 
 export default function ChatScreen() {
-  const { messages, sendTextMessage, sendVoiceMessage, sendImageMessage, deleteMessage, clearMessages, isTyping, requestScreenshot, sessions, loadMoreMessages, hasMoreMessages } =
+  const { messages, sendTextMessage, sendVoiceMessage, sendImageMessage, deleteMessage, clearMessages, isTyping, requestScreenshot, sessions, switchSession, loadMoreMessages, hasMoreMessages, incomingToast, dismissToast } =
     useChat();
   const { status } = useConnection();
   const { colors, mode, cycleMode } = useTheme();
@@ -34,6 +35,13 @@
   useEffect(() => {
     return onPlayingChange((uri) => setAudioPlaying(uri !== null));
   }, []);
+
+  const handleToastTap = useCallback(() => {
+    if (incomingToast) {
+      switchSession(incomingToast.sessionId);
+      dismissToast();
+    }
+  }, [incomingToast, switchSession, dismissToast]);
 
   const handleScreenshot = useCallback(() => {
     requestScreenshot();
@@ -252,7 +260,16 @@
       </View>
 
       {/* Message list */}
-      <View style={{ flex: 1 }}>
+      <View style={{ flex: 1, position: "relative" }}>
+        {/* Toast for other-session incoming messages */}
+        {incomingToast && (
+          <IncomingToast
+            sessionName={incomingToast.sessionName}
+            preview={incomingToast.preview}
+            onTap={handleToastTap}
+            onDismiss={dismissToast}
+          />
+        )}
         {messages.length === 0 ? (
           <View style={{ flex: 1, alignItems: "center", justifyContent: "center", gap: 16 }}>
             <View
diff --git a/components/ui/IncomingToast.tsx b/components/ui/IncomingToast.tsx
new file mode 100644
index 0000000..779c82e
--- /dev/null
+++ b/components/ui/IncomingToast.tsx
@@ -0,0 +1,88 @@
+import React, { useEffect, useRef } from "react";
+import { Animated, Pressable, Text, View } from "react-native";
+import { useTheme } from "../../contexts/ThemeContext";
+
+interface IncomingToastProps {
+  sessionName: string;
+  preview: string;
+  onTap: () => void;
+  onDismiss: () => void;
+}
+
+const DISPLAY_MS = 4000;
+
+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;
+
+  useEffect(() => {
+    // Slide in
+    Animated.parallel([
+      Animated.timing(opacity, { toValue: 1, duration: 200, useNativeDriver: true }),
+      Animated.timing(translateY, { toValue: 0, duration: 200, useNativeDriver: true }),
+    ]).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());
+    }, DISPLAY_MS);
+
+    return () => clearTimeout(timer);
+  }, []);
+
+  return (
+    <Animated.View
+      style={{
+        position: "absolute",
+        top: 0,
+        left: 12,
+        right: 12,
+        zIndex: 100,
+        opacity,
+        transform: [{ translateY }],
+      }}
+    >
+      <Pressable
+        onPress={onTap}
+        style={({ pressed }) => ({
+          flexDirection: "row",
+          alignItems: "center",
+          gap: 10,
+          paddingHorizontal: 14,
+          paddingVertical: 10,
+          borderRadius: 12,
+          backgroundColor: pressed ? colors.bgTertiary : colors.bgSecondary,
+          borderWidth: 1,
+          borderColor: colors.border,
+          shadowColor: "#000",
+          shadowOffset: { width: 0, height: 2 },
+          shadowOpacity: 0.25,
+          shadowRadius: 4,
+          elevation: 5,
+        })}
+      >
+        <View
+          style={{
+            width: 8,
+            height: 8,
+            borderRadius: 4,
+            backgroundColor: "#3b82f6",
+          }}
+        />
+        <View style={{ flex: 1 }}>
+          <Text style={{ color: colors.text, fontSize: 13, fontWeight: "700" }} numberOfLines={1}>
+            {sessionName}
+          </Text>
+          <Text style={{ color: colors.textMuted, fontSize: 12, marginTop: 1 }} numberOfLines={1}>
+            {preview}
+          </Text>
+        </View>
+        <Text style={{ color: colors.textMuted, fontSize: 11 }}>tap to switch</Text>
+      </Pressable>
+    </Animated.View>
+  );
+}
diff --git a/contexts/ChatContext.tsx b/contexts/ChatContext.tsx
index 5269516..b109bb7 100644
--- a/contexts/ChatContext.tsx
+++ b/contexts/ChatContext.tsx
@@ -116,6 +116,12 @@
 
 // --- Context ---
 
+interface IncomingToast {
+  sessionId: string;
+  sessionName: string;
+  preview: string;
+}
+
 interface ChatContextValue {
   messages: Message[];
   sendTextMessage: (text: string) => void;
@@ -136,6 +142,8 @@
   loadMoreMessages: () => void;
   hasMoreMessages: boolean;
   unreadCounts: Record<string, number>;
+  incomingToast: IncomingToast | null;
+  dismissToast: () => void;
   latestScreenshot: string | null;
   requestScreenshot: () => void;
   sendNavKey: (key: string) => void;
@@ -156,8 +164,11 @@
   const [messages, setMessages] = useState<Message[]>([]);
   // Unread counts for non-active sessions
   const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
-  // Typing indicator from server
+  // Per-session typing indicator (sessionId → boolean)
+  const typingMapRef = useRef<Record<string, boolean>>({});
   const [isTyping, setIsTyping] = useState(false);
+  // Toast for other-session incoming messages
+  const [incomingToast, setIncomingToast] = useState<{ sessionId: string; sessionName: string; preview: string } | null>(null);
   // PAI projects list
   const [projects, setProjects] = useState<PaiProject[]>([]);
   // Pagination: does the active session have more messages in storage?
@@ -199,6 +210,9 @@
             delete next[active.id];
             return next;
           });
+          // Sync typing indicator for the new active session
+          const activeTyping = typingMapRef.current[active.id] ?? false;
+          setIsTyping(activeTyping);
         }
         activeSessionIdRef.current = active.id;
         return active.id;
@@ -251,6 +265,16 @@
         ...u,
         [sessionId]: (u[sessionId] ?? 0) + 1,
       }));
+      // Show 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 });
+          return prev;
+        });
+      }
     }
   }, []);
 
@@ -359,26 +383,12 @@
           break;
         }
         case "session_switched": {
-          const msg: Message = {
-            id: generateId(),
-            role: "system",
-            type: "text",
-            content: `Switched to ${data.name}`,
-            timestamp: Date.now(),
-          };
-          addMessageToActive(msg);
+          // Just refresh session list — no system message needed
           sendCommand("sessions");
           break;
         }
         case "session_renamed": {
-          const msg: Message = {
-            id: generateId(),
-            role: "system",
-            type: "text",
-            content: `Renamed to ${data.name}`,
-            timestamp: Date.now(),
-          };
-          addMessageToActive(msg);
+          // Just refresh session list — no system message needed
           sendCommand("sessions");
           break;
         }
@@ -388,7 +398,11 @@
           break;
         }
         case "typing": {
-          setIsTyping(data.typing);
+          const typingSession = (data.sessionId as string) || activeSessionIdRef.current || "_global";
+          typingMapRef.current[typingSession] = !!data.typing;
+          // Only show typing indicator if it's for the active session
+          const activeTyping = typingMapRef.current[activeSessionIdRef.current ?? ""] ?? false;
+          setIsTyping(activeTyping);
           break;
         }
         case "status": {
@@ -545,6 +559,10 @@
     sendCommand("projects");
   }, [sendCommand]);
 
+  const dismissToast = useCallback(() => {
+    setIncomingToast(null);
+  }, []);
+
   const loadMoreMessages = useCallback(() => {
     const sessId = activeSessionIdRef.current;
     if (!sessId) return;
@@ -595,6 +613,8 @@
         loadMoreMessages,
         hasMoreMessages,
         unreadCounts,
+        incomingToast,
+        dismissToast,
         latestScreenshot,
         requestScreenshot,
         sendNavKey,
diff --git a/types/index.ts b/types/index.ts
index bea9e77..613d2d6 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -108,6 +108,7 @@
 export interface WsIncomingTyping {
   type: "typing";
   typing: boolean;
+  sessionId?: string;
 }
 
 export interface WsIncomingError {

--
Gitblit v1.3.1