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