Matthias Nott
2026-03-08 d832f656599b153be8826bc43e1832209b2a1bf6
feat: per-session typing, incoming toast, remove switched-to messages

- Typing indicator is now per-session (only shows for active session)
- New IncomingToast component: slides down when another session gets a message
- Shows session name + preview, tappable to switch, auto-dismisses after 4s
- Removed "Switched to X" and "Renamed to X" system messages from chat
- Added sessionId to WsIncomingTyping type
1 files added
3 files modified
changed files
app/chat.tsx patch | view | blame | history
components/ui/IncomingToast.tsx patch | view | blame | history
contexts/ChatContext.tsx patch | view | blame | history
types/index.ts patch | view | blame | history
app/chat.tsx
....@@ -10,6 +10,7 @@
1010 import { CommandBar, TextModeCommandBar } from "../components/chat/CommandBar";
1111 import { ImageCaptionModal } from "../components/chat/ImageCaptionModal";
1212 import { StatusDot } from "../components/ui/StatusDot";
13
+import { IncomingToast } from "../components/ui/IncomingToast";
1314 import { SessionDrawer } from "../components/SessionDrawer";
1415 import { playAudio, stopPlayback, isPlaying, onPlayingChange } from "../services/audio";
1516
....@@ -20,7 +21,7 @@
2021 }
2122
2223 export default function ChatScreen() {
23
- const { messages, sendTextMessage, sendVoiceMessage, sendImageMessage, deleteMessage, clearMessages, isTyping, requestScreenshot, sessions, loadMoreMessages, hasMoreMessages } =
24
+ const { messages, sendTextMessage, sendVoiceMessage, sendImageMessage, deleteMessage, clearMessages, isTyping, requestScreenshot, sessions, switchSession, loadMoreMessages, hasMoreMessages, incomingToast, dismissToast } =
2425 useChat();
2526 const { status } = useConnection();
2627 const { colors, mode, cycleMode } = useTheme();
....@@ -34,6 +35,13 @@
3435 useEffect(() => {
3536 return onPlayingChange((uri) => setAudioPlaying(uri !== null));
3637 }, []);
38
+
39
+ const handleToastTap = useCallback(() => {
40
+ if (incomingToast) {
41
+ switchSession(incomingToast.sessionId);
42
+ dismissToast();
43
+ }
44
+ }, [incomingToast, switchSession, dismissToast]);
3745
3846 const handleScreenshot = useCallback(() => {
3947 requestScreenshot();
....@@ -252,7 +260,16 @@
252260 </View>
253261
254262 {/* Message list */}
255
- <View style={{ flex: 1 }}>
263
+ <View style={{ flex: 1, position: "relative" }}>
264
+ {/* Toast for other-session incoming messages */}
265
+ {incomingToast && (
266
+ <IncomingToast
267
+ sessionName={incomingToast.sessionName}
268
+ preview={incomingToast.preview}
269
+ onTap={handleToastTap}
270
+ onDismiss={dismissToast}
271
+ />
272
+ )}
256273 {messages.length === 0 ? (
257274 <View style={{ flex: 1, alignItems: "center", justifyContent: "center", gap: 16 }}>
258275 <View
components/ui/IncomingToast.tsx
....@@ -0,0 +1,88 @@
1
+import React, { useEffect, useRef } from "react";
2
+import { Animated, Pressable, Text, View } from "react-native";
3
+import { useTheme } from "../../contexts/ThemeContext";
4
+
5
+interface IncomingToastProps {
6
+ sessionName: string;
7
+ preview: string;
8
+ onTap: () => void;
9
+ onDismiss: () => void;
10
+}
11
+
12
+const DISPLAY_MS = 4000;
13
+
14
+export function IncomingToast({ sessionName, preview, onTap, onDismiss }: IncomingToastProps) {
15
+ const { colors } = useTheme();
16
+ const opacity = useRef(new Animated.Value(0)).current;
17
+ const translateY = useRef(new Animated.Value(-40)).current;
18
+
19
+ useEffect(() => {
20
+ // Slide in
21
+ Animated.parallel([
22
+ Animated.timing(opacity, { toValue: 1, duration: 200, useNativeDriver: true }),
23
+ Animated.timing(translateY, { toValue: 0, duration: 200, useNativeDriver: true }),
24
+ ]).start();
25
+
26
+ // Auto-dismiss
27
+ const timer = setTimeout(() => {
28
+ Animated.parallel([
29
+ Animated.timing(opacity, { toValue: 0, duration: 200, useNativeDriver: true }),
30
+ Animated.timing(translateY, { toValue: -40, duration: 200, useNativeDriver: true }),
31
+ ]).start(() => onDismiss());
32
+ }, DISPLAY_MS);
33
+
34
+ return () => clearTimeout(timer);
35
+ }, []);
36
+
37
+ return (
38
+ <Animated.View
39
+ style={{
40
+ position: "absolute",
41
+ top: 0,
42
+ left: 12,
43
+ right: 12,
44
+ zIndex: 100,
45
+ opacity,
46
+ transform: [{ translateY }],
47
+ }}
48
+ >
49
+ <Pressable
50
+ onPress={onTap}
51
+ style={({ pressed }) => ({
52
+ flexDirection: "row",
53
+ alignItems: "center",
54
+ gap: 10,
55
+ paddingHorizontal: 14,
56
+ paddingVertical: 10,
57
+ borderRadius: 12,
58
+ backgroundColor: pressed ? colors.bgTertiary : colors.bgSecondary,
59
+ borderWidth: 1,
60
+ borderColor: colors.border,
61
+ shadowColor: "#000",
62
+ shadowOffset: { width: 0, height: 2 },
63
+ shadowOpacity: 0.25,
64
+ shadowRadius: 4,
65
+ elevation: 5,
66
+ })}
67
+ >
68
+ <View
69
+ style={{
70
+ width: 8,
71
+ height: 8,
72
+ borderRadius: 4,
73
+ backgroundColor: "#3b82f6",
74
+ }}
75
+ />
76
+ <View style={{ flex: 1 }}>
77
+ <Text style={{ color: colors.text, fontSize: 13, fontWeight: "700" }} numberOfLines={1}>
78
+ {sessionName}
79
+ </Text>
80
+ <Text style={{ color: colors.textMuted, fontSize: 12, marginTop: 1 }} numberOfLines={1}>
81
+ {preview}
82
+ </Text>
83
+ </View>
84
+ <Text style={{ color: colors.textMuted, fontSize: 11 }}>tap to switch</Text>
85
+ </Pressable>
86
+ </Animated.View>
87
+ );
88
+}
contexts/ChatContext.tsx
....@@ -116,6 +116,12 @@
116116
117117 // --- Context ---
118118
119
+interface IncomingToast {
120
+ sessionId: string;
121
+ sessionName: string;
122
+ preview: string;
123
+}
124
+
119125 interface ChatContextValue {
120126 messages: Message[];
121127 sendTextMessage: (text: string) => void;
....@@ -136,6 +142,8 @@
136142 loadMoreMessages: () => void;
137143 hasMoreMessages: boolean;
138144 unreadCounts: Record<string, number>;
145
+ incomingToast: IncomingToast | null;
146
+ dismissToast: () => void;
139147 latestScreenshot: string | null;
140148 requestScreenshot: () => void;
141149 sendNavKey: (key: string) => void;
....@@ -156,8 +164,11 @@
156164 const [messages, setMessages] = useState<Message[]>([]);
157165 // Unread counts for non-active sessions
158166 const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
159
- // Typing indicator from server
167
+ // Per-session typing indicator (sessionId → boolean)
168
+ const typingMapRef = useRef<Record<string, boolean>>({});
160169 const [isTyping, setIsTyping] = useState(false);
170
+ // Toast for other-session incoming messages
171
+ const [incomingToast, setIncomingToast] = useState<{ sessionId: string; sessionName: string; preview: string } | null>(null);
161172 // PAI projects list
162173 const [projects, setProjects] = useState<PaiProject[]>([]);
163174 // Pagination: does the active session have more messages in storage?
....@@ -199,6 +210,9 @@
199210 delete next[active.id];
200211 return next;
201212 });
213
+ // Sync typing indicator for the new active session
214
+ const activeTyping = typingMapRef.current[active.id] ?? false;
215
+ setIsTyping(activeTyping);
202216 }
203217 activeSessionIdRef.current = active.id;
204218 return active.id;
....@@ -251,6 +265,16 @@
251265 ...u,
252266 [sessionId]: (u[sessionId] ?? 0) + 1,
253267 }));
268
+ // Show toast for other-session messages (assistant only, skip system noise)
269
+ if (msg.role === "assistant") {
270
+ setSessions((prev) => {
271
+ const session = prev.find((s) => s.id === sessionId);
272
+ const name = session?.name ?? sessionId.slice(0, 8);
273
+ const preview = msg.type === "voice" ? "🎤 Voice note" : msg.type === "image" ? "📷 Image" : (msg.content ?? "").slice(0, 60);
274
+ setIncomingToast({ sessionId, sessionName: name, preview });
275
+ return prev;
276
+ });
277
+ }
254278 }
255279 }, []);
256280
....@@ -359,26 +383,12 @@
359383 break;
360384 }
361385 case "session_switched": {
362
- const msg: Message = {
363
- id: generateId(),
364
- role: "system",
365
- type: "text",
366
- content: `Switched to ${data.name}`,
367
- timestamp: Date.now(),
368
- };
369
- addMessageToActive(msg);
386
+ // Just refresh session list — no system message needed
370387 sendCommand("sessions");
371388 break;
372389 }
373390 case "session_renamed": {
374
- const msg: Message = {
375
- id: generateId(),
376
- role: "system",
377
- type: "text",
378
- content: `Renamed to ${data.name}`,
379
- timestamp: Date.now(),
380
- };
381
- addMessageToActive(msg);
391
+ // Just refresh session list — no system message needed
382392 sendCommand("sessions");
383393 break;
384394 }
....@@ -388,7 +398,11 @@
388398 break;
389399 }
390400 case "typing": {
391
- setIsTyping(data.typing);
401
+ const typingSession = (data.sessionId as string) || activeSessionIdRef.current || "_global";
402
+ typingMapRef.current[typingSession] = !!data.typing;
403
+ // Only show typing indicator if it's for the active session
404
+ const activeTyping = typingMapRef.current[activeSessionIdRef.current ?? ""] ?? false;
405
+ setIsTyping(activeTyping);
392406 break;
393407 }
394408 case "status": {
....@@ -545,6 +559,10 @@
545559 sendCommand("projects");
546560 }, [sendCommand]);
547561
562
+ const dismissToast = useCallback(() => {
563
+ setIncomingToast(null);
564
+ }, []);
565
+
548566 const loadMoreMessages = useCallback(() => {
549567 const sessId = activeSessionIdRef.current;
550568 if (!sessId) return;
....@@ -595,6 +613,8 @@
595613 loadMoreMessages,
596614 hasMoreMessages,
597615 unreadCounts,
616
+ incomingToast,
617
+ dismissToast,
598618 latestScreenshot,
599619 requestScreenshot,
600620 sendNavKey,
types/index.ts
....@@ -108,6 +108,7 @@
108108 export interface WsIncomingTyping {
109109 type: "typing";
110110 typing: boolean;
111
+ sessionId?: string;
111112 }
112113
113114 export interface WsIncomingError {