Matthias Nott
2026-03-08 4c266155785aad5050ebff7211e3d5f9e15c3238
feat: explicit session addressing + toast queue + solid toast styling

Every outgoing message now includes sessionId so the server
never guesses routing. Toast notifications use solid background
with spring slide animation. Toast queue shows one at a time.
4 files modified
changed files
components/ui/IncomingToast.tsx patch | view | blame | history
contexts/ChatContext.tsx patch | view | blame | history
contexts/ConnectionContext.tsx patch | view | blame | history
types/index.ts patch | view | blame | history
components/ui/IncomingToast.tsx
....@@ -13,22 +13,24 @@
1313
1414 export function IncomingToast({ sessionName, preview, onTap, onDismiss }: IncomingToastProps) {
1515 const { colors } = useTheme();
16
- const opacity = useRef(new Animated.Value(0)).current;
17
- const translateY = useRef(new Animated.Value(-40)).current;
16
+ const translateY = useRef(new Animated.Value(-60)).current;
1817
1918 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();
19
+ // Slide in from above (no opacity — keeps background solid)
20
+ Animated.spring(translateY, {
21
+ toValue: 0,
22
+ useNativeDriver: true,
23
+ tension: 80,
24
+ friction: 10,
25
+ }).start();
2526
2627 // Auto-dismiss
2728 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());
29
+ Animated.timing(translateY, {
30
+ toValue: -60,
31
+ duration: 200,
32
+ useNativeDriver: true,
33
+ }).start(() => onDismiss());
3234 }, DISPLAY_MS);
3335
3436 return () => clearTimeout(timer);
....@@ -38,11 +40,10 @@
3840 <Animated.View
3941 style={{
4042 position: "absolute",
41
- top: 0,
43
+ top: 4,
4244 left: 12,
4345 right: 12,
4446 zIndex: 100,
45
- opacity,
4647 transform: [{ translateY }],
4748 }}
4849 >
....@@ -57,31 +58,31 @@
5758 borderRadius: 12,
5859 backgroundColor: pressed ? colors.bgTertiary : colors.bgSecondary,
5960 borderWidth: 1,
60
- borderColor: colors.border,
61
+ borderColor: colors.accent,
6162 shadowColor: "#000",
62
- shadowOffset: { width: 0, height: 2 },
63
- shadowOpacity: 0.25,
64
- shadowRadius: 4,
65
- elevation: 5,
63
+ shadowOffset: { width: 0, height: 4 },
64
+ shadowOpacity: 0.4,
65
+ shadowRadius: 8,
66
+ elevation: 8,
6667 })}
6768 >
6869 <View
6970 style={{
70
- width: 8,
71
- height: 8,
72
- borderRadius: 4,
73
- backgroundColor: "#3b82f6",
71
+ width: 10,
72
+ height: 10,
73
+ borderRadius: 5,
74
+ backgroundColor: colors.accent,
7475 }}
7576 />
7677 <View style={{ flex: 1 }}>
77
- <Text style={{ color: colors.text, fontSize: 13, fontWeight: "700" }} numberOfLines={1}>
78
+ <Text style={{ color: colors.text, fontSize: 14, fontWeight: "700" }} numberOfLines={1}>
7879 {sessionName}
7980 </Text>
80
- <Text style={{ color: colors.textMuted, fontSize: 12, marginTop: 1 }} numberOfLines={1}>
81
+ <Text style={{ color: colors.textMuted, fontSize: 12, marginTop: 2 }} numberOfLines={1}>
8182 {preview}
8283 </Text>
8384 </View>
84
- <Text style={{ color: colors.textMuted, fontSize: 11 }}>tap to switch</Text>
85
+ <Text style={{ color: colors.accent, fontSize: 11, fontWeight: "600" }}>switch</Text>
8586 </Pressable>
8687 </Animated.View>
8788 );
contexts/ChatContext.tsx
....@@ -167,7 +167,8 @@
167167 // Per-session typing indicator (sessionId → boolean)
168168 const typingMapRef = useRef<Record<string, boolean>>({});
169169 const [isTyping, setIsTyping] = useState(false);
170
- // Toast for other-session incoming messages
170
+ // Toast queue for other-session incoming messages (show one at a time)
171
+ const toastQueueRef = useRef<{ sessionId: string; sessionName: string; preview: string }[]>([]);
171172 const [incomingToast, setIncomingToast] = useState<{ sessionId: string; sessionName: string; preview: string } | null>(null);
172173 // PAI projects list
173174 const [projects, setProjects] = useState<PaiProject[]>([]);
....@@ -265,13 +266,19 @@
265266 ...u,
266267 [sessionId]: (u[sessionId] ?? 0) + 1,
267268 }));
268
- // Show toast for other-session messages (assistant only, skip system noise)
269
+ // Queue toast for other-session messages (assistant only, skip system noise)
269270 if (msg.role === "assistant") {
270271 setSessions((prev) => {
271272 const session = prev.find((s) => s.id === sessionId);
272273 const name = session?.name ?? sessionId.slice(0, 8);
273274 const preview = msg.type === "voice" ? "🎤 Voice note" : msg.type === "image" ? "📷 Image" : (msg.content ?? "").slice(0, 60);
274
- setIncomingToast({ sessionId, sessionName: name, preview });
275
+ const toast = { sessionId, sessionName: name, preview };
276
+ // If no toast is showing, show immediately; otherwise queue
277
+ setIncomingToast((current) => {
278
+ if (current === null) return toast;
279
+ toastQueueRef.current.push(toast);
280
+ return current;
281
+ });
275282 return prev;
276283 });
277284 }
....@@ -444,7 +451,7 @@
444451 status: "sending",
445452 };
446453 addMessageToActive(msg);
447
- const sent = wsSend(text);
454
+ const sent = wsSend(text, activeSessionIdRef.current ?? undefined);
448455 updateMessageStatus(id, sent ? "sent" : "error");
449456 },
450457 [wsSend, addMessageToActive, updateMessageStatus]
....@@ -466,7 +473,7 @@
466473 addMessageToActive(msg);
467474 try {
468475 const base64 = await encodeAudioToBase64(audioUri);
469
- const sent = wsVoice(base64, "", id);
476
+ const sent = wsVoice(base64, "", id, activeSessionIdRef.current ?? undefined);
470477 updateMessageStatus(id, sent ? "sent" : "error");
471478 } catch (err) {
472479 console.error("Failed to encode audio:", err);
....@@ -489,7 +496,7 @@
489496 status: "sending",
490497 };
491498 addMessageToActive(msg);
492
- const sent = wsImageSend(imageBase64, caption, mimeType);
499
+ const sent = wsImageSend(imageBase64, caption, mimeType, activeSessionIdRef.current ?? undefined);
493500 updateMessageStatus(id, sent ? "sent" : "error");
494501 },
495502 [wsImageSend, addMessageToActive, updateMessageStatus]
....@@ -560,7 +567,9 @@
560567 }, [sendCommand]);
561568
562569 const dismissToast = useCallback(() => {
563
- setIncomingToast(null);
570
+ // Show next queued toast, or clear
571
+ const next = toastQueueRef.current.shift();
572
+ setIncomingToast(next ?? null);
564573 }, []);
565574
566575 const loadMoreMessages = useCallback(() => {
contexts/ConnectionContext.tsx
....@@ -23,10 +23,10 @@
2323 status: ConnectionStatus;
2424 connect: (config?: ServerConfig) => void;
2525 disconnect: () => void;
26
- sendTextMessage: (text: string) => boolean;
27
- sendVoiceMessage: (audioBase64: string, transcript?: string, messageId?: string) => boolean;
28
- sendImageMessage: (imageBase64: string, caption: string, mimeType: string) => boolean;
29
- sendCommand: (command: string, args?: Record<string, unknown>) => boolean;
26
+ sendTextMessage: (text: string, sessionId?: string) => boolean;
27
+ sendVoiceMessage: (audioBase64: string, transcript?: string, messageId?: string, sessionId?: string) => boolean;
28
+ sendImageMessage: (imageBase64: string, caption: string, mimeType: string, sessionId?: string) => boolean;
29
+ sendCommand: (command: string, args?: Record<string, unknown>, sessionId?: string) => boolean;
3030 saveServerConfig: (config: ServerConfig) => Promise<void>;
3131 onMessageReceived: React.MutableRefObject<
3232 ((data: WsIncoming) => void) | null
....@@ -116,32 +116,33 @@
116116 setServerConfig(config);
117117 }, []);
118118
119
- const sendTextMessage = useCallback((text: string): boolean => {
120
- return wsClient.send({ type: "text", content: text });
119
+ const sendTextMessage = useCallback((text: string, sessionId?: string): boolean => {
120
+ return wsClient.send({ type: "text", content: text, sessionId });
121121 }, []);
122122
123123 const sendVoiceMessage = useCallback(
124
- (audioBase64: string, transcript: string = "", messageId?: string): boolean => {
124
+ (audioBase64: string, transcript: string = "", messageId?: string, sessionId?: string): boolean => {
125125 return wsClient.send({
126126 type: "voice",
127127 content: transcript,
128128 audioBase64,
129129 messageId,
130
+ sessionId,
130131 });
131132 },
132133 []
133134 );
134135
135136 const sendImageMessage = useCallback(
136
- (imageBase64: string, caption: string = "", mimeType: string = "image/jpeg"): boolean => {
137
- return wsClient.send({ type: "image", imageBase64, caption, mimeType });
137
+ (imageBase64: string, caption: string = "", mimeType: string = "image/jpeg", sessionId?: string): boolean => {
138
+ return wsClient.send({ type: "image", imageBase64, caption, mimeType, sessionId });
138139 },
139140 []
140141 );
141142
142143 const sendCommand = useCallback(
143
- (command: string, args?: Record<string, unknown>): boolean => {
144
- const msg: WsOutgoing = { type: "command", command, args };
144
+ (command: string, args?: Record<string, unknown>, sessionId?: string): boolean => {
145
+ const msg: WsOutgoing = { type: "command", command, args, sessionId };
145146 return wsClient.send(msg as any);
146147 },
147148 []
types/index.ts
....@@ -28,6 +28,7 @@
2828 export interface WsTextMessage {
2929 type: "text";
3030 content: string;
31
+ sessionId?: string;
3132 }
3233
3334 export interface WsVoiceMessage {
....@@ -35,6 +36,7 @@
3536 audioBase64: string;
3637 content: string;
3738 messageId?: string;
39
+ sessionId?: string;
3840 }
3941
4042 export interface WsImageMessage {
....@@ -42,12 +44,14 @@
4244 imageBase64: string;
4345 caption: string;
4446 mimeType: string;
47
+ sessionId?: string;
4548 }
4649
4750 export interface WsCommandMessage {
4851 type: "command";
4952 command: string;
5053 args?: Record<string, unknown>;
54
+ sessionId?: string;
5155 }
5256
5357 export type WsOutgoing = WsTextMessage | WsVoiceMessage | WsImageMessage | WsCommandMessage;