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.
| .. | .. |
|---|
| 13 | 13 | |
|---|
| 14 | 14 | export function IncomingToast({ sessionName, preview, onTap, onDismiss }: IncomingToastProps) { |
|---|
| 15 | 15 | 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; |
|---|
| 18 | 17 | |
|---|
| 19 | 18 | 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(); |
|---|
| 25 | 26 | |
|---|
| 26 | 27 | // Auto-dismiss |
|---|
| 27 | 28 | 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()); |
|---|
| 32 | 34 | }, DISPLAY_MS); |
|---|
| 33 | 35 | |
|---|
| 34 | 36 | return () => clearTimeout(timer); |
|---|
| .. | .. |
|---|
| 38 | 40 | <Animated.View |
|---|
| 39 | 41 | style={{ |
|---|
| 40 | 42 | position: "absolute", |
|---|
| 41 | | - top: 0, |
|---|
| 43 | + top: 4, |
|---|
| 42 | 44 | left: 12, |
|---|
| 43 | 45 | right: 12, |
|---|
| 44 | 46 | zIndex: 100, |
|---|
| 45 | | - opacity, |
|---|
| 46 | 47 | transform: [{ translateY }], |
|---|
| 47 | 48 | }} |
|---|
| 48 | 49 | > |
|---|
| .. | .. |
|---|
| 57 | 58 | borderRadius: 12, |
|---|
| 58 | 59 | backgroundColor: pressed ? colors.bgTertiary : colors.bgSecondary, |
|---|
| 59 | 60 | borderWidth: 1, |
|---|
| 60 | | - borderColor: colors.border, |
|---|
| 61 | + borderColor: colors.accent, |
|---|
| 61 | 62 | 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, |
|---|
| 66 | 67 | })} |
|---|
| 67 | 68 | > |
|---|
| 68 | 69 | <View |
|---|
| 69 | 70 | 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, |
|---|
| 74 | 75 | }} |
|---|
| 75 | 76 | /> |
|---|
| 76 | 77 | <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}> |
|---|
| 78 | 79 | {sessionName} |
|---|
| 79 | 80 | </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}> |
|---|
| 81 | 82 | {preview} |
|---|
| 82 | 83 | </Text> |
|---|
| 83 | 84 | </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> |
|---|
| 85 | 86 | </Pressable> |
|---|
| 86 | 87 | </Animated.View> |
|---|
| 87 | 88 | ); |
|---|
| .. | .. |
|---|
| 167 | 167 | // Per-session typing indicator (sessionId → boolean) |
|---|
| 168 | 168 | const typingMapRef = useRef<Record<string, boolean>>({}); |
|---|
| 169 | 169 | 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 }[]>([]); |
|---|
| 171 | 172 | const [incomingToast, setIncomingToast] = useState<{ sessionId: string; sessionName: string; preview: string } | null>(null); |
|---|
| 172 | 173 | // PAI projects list |
|---|
| 173 | 174 | const [projects, setProjects] = useState<PaiProject[]>([]); |
|---|
| .. | .. |
|---|
| 265 | 266 | ...u, |
|---|
| 266 | 267 | [sessionId]: (u[sessionId] ?? 0) + 1, |
|---|
| 267 | 268 | })); |
|---|
| 268 | | - // Show toast for other-session messages (assistant only, skip system noise) |
|---|
| 269 | + // Queue toast for other-session messages (assistant only, skip system noise) |
|---|
| 269 | 270 | if (msg.role === "assistant") { |
|---|
| 270 | 271 | setSessions((prev) => { |
|---|
| 271 | 272 | const session = prev.find((s) => s.id === sessionId); |
|---|
| 272 | 273 | const name = session?.name ?? sessionId.slice(0, 8); |
|---|
| 273 | 274 | 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 | + }); |
|---|
| 275 | 282 | return prev; |
|---|
| 276 | 283 | }); |
|---|
| 277 | 284 | } |
|---|
| .. | .. |
|---|
| 444 | 451 | status: "sending", |
|---|
| 445 | 452 | }; |
|---|
| 446 | 453 | addMessageToActive(msg); |
|---|
| 447 | | - const sent = wsSend(text); |
|---|
| 454 | + const sent = wsSend(text, activeSessionIdRef.current ?? undefined); |
|---|
| 448 | 455 | updateMessageStatus(id, sent ? "sent" : "error"); |
|---|
| 449 | 456 | }, |
|---|
| 450 | 457 | [wsSend, addMessageToActive, updateMessageStatus] |
|---|
| .. | .. |
|---|
| 466 | 473 | addMessageToActive(msg); |
|---|
| 467 | 474 | try { |
|---|
| 468 | 475 | const base64 = await encodeAudioToBase64(audioUri); |
|---|
| 469 | | - const sent = wsVoice(base64, "", id); |
|---|
| 476 | + const sent = wsVoice(base64, "", id, activeSessionIdRef.current ?? undefined); |
|---|
| 470 | 477 | updateMessageStatus(id, sent ? "sent" : "error"); |
|---|
| 471 | 478 | } catch (err) { |
|---|
| 472 | 479 | console.error("Failed to encode audio:", err); |
|---|
| .. | .. |
|---|
| 489 | 496 | status: "sending", |
|---|
| 490 | 497 | }; |
|---|
| 491 | 498 | addMessageToActive(msg); |
|---|
| 492 | | - const sent = wsImageSend(imageBase64, caption, mimeType); |
|---|
| 499 | + const sent = wsImageSend(imageBase64, caption, mimeType, activeSessionIdRef.current ?? undefined); |
|---|
| 493 | 500 | updateMessageStatus(id, sent ? "sent" : "error"); |
|---|
| 494 | 501 | }, |
|---|
| 495 | 502 | [wsImageSend, addMessageToActive, updateMessageStatus] |
|---|
| .. | .. |
|---|
| 560 | 567 | }, [sendCommand]); |
|---|
| 561 | 568 | |
|---|
| 562 | 569 | const dismissToast = useCallback(() => { |
|---|
| 563 | | - setIncomingToast(null); |
|---|
| 570 | + // Show next queued toast, or clear |
|---|
| 571 | + const next = toastQueueRef.current.shift(); |
|---|
| 572 | + setIncomingToast(next ?? null); |
|---|
| 564 | 573 | }, []); |
|---|
| 565 | 574 | |
|---|
| 566 | 575 | const loadMoreMessages = useCallback(() => { |
|---|
| .. | .. |
|---|
| 23 | 23 | status: ConnectionStatus; |
|---|
| 24 | 24 | connect: (config?: ServerConfig) => void; |
|---|
| 25 | 25 | 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; |
|---|
| 30 | 30 | saveServerConfig: (config: ServerConfig) => Promise<void>; |
|---|
| 31 | 31 | onMessageReceived: React.MutableRefObject< |
|---|
| 32 | 32 | ((data: WsIncoming) => void) | null |
|---|
| .. | .. |
|---|
| 116 | 116 | setServerConfig(config); |
|---|
| 117 | 117 | }, []); |
|---|
| 118 | 118 | |
|---|
| 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 }); |
|---|
| 121 | 121 | }, []); |
|---|
| 122 | 122 | |
|---|
| 123 | 123 | const sendVoiceMessage = useCallback( |
|---|
| 124 | | - (audioBase64: string, transcript: string = "", messageId?: string): boolean => { |
|---|
| 124 | + (audioBase64: string, transcript: string = "", messageId?: string, sessionId?: string): boolean => { |
|---|
| 125 | 125 | return wsClient.send({ |
|---|
| 126 | 126 | type: "voice", |
|---|
| 127 | 127 | content: transcript, |
|---|
| 128 | 128 | audioBase64, |
|---|
| 129 | 129 | messageId, |
|---|
| 130 | + sessionId, |
|---|
| 130 | 131 | }); |
|---|
| 131 | 132 | }, |
|---|
| 132 | 133 | [] |
|---|
| 133 | 134 | ); |
|---|
| 134 | 135 | |
|---|
| 135 | 136 | 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 }); |
|---|
| 138 | 139 | }, |
|---|
| 139 | 140 | [] |
|---|
| 140 | 141 | ); |
|---|
| 141 | 142 | |
|---|
| 142 | 143 | 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 }; |
|---|
| 145 | 146 | return wsClient.send(msg as any); |
|---|
| 146 | 147 | }, |
|---|
| 147 | 148 | [] |
|---|
| .. | .. |
|---|
| 28 | 28 | export interface WsTextMessage { |
|---|
| 29 | 29 | type: "text"; |
|---|
| 30 | 30 | content: string; |
|---|
| 31 | + sessionId?: string; |
|---|
| 31 | 32 | } |
|---|
| 32 | 33 | |
|---|
| 33 | 34 | export interface WsVoiceMessage { |
|---|
| .. | .. |
|---|
| 35 | 36 | audioBase64: string; |
|---|
| 36 | 37 | content: string; |
|---|
| 37 | 38 | messageId?: string; |
|---|
| 39 | + sessionId?: string; |
|---|
| 38 | 40 | } |
|---|
| 39 | 41 | |
|---|
| 40 | 42 | export interface WsImageMessage { |
|---|
| .. | .. |
|---|
| 42 | 44 | imageBase64: string; |
|---|
| 43 | 45 | caption: string; |
|---|
| 44 | 46 | mimeType: string; |
|---|
| 47 | + sessionId?: string; |
|---|
| 45 | 48 | } |
|---|
| 46 | 49 | |
|---|
| 47 | 50 | export interface WsCommandMessage { |
|---|
| 48 | 51 | type: "command"; |
|---|
| 49 | 52 | command: string; |
|---|
| 50 | 53 | args?: Record<string, unknown>; |
|---|
| 54 | + sessionId?: string; |
|---|
| 51 | 55 | } |
|---|
| 52 | 56 | |
|---|
| 53 | 57 | export type WsOutgoing = WsTextMessage | WsVoiceMessage | WsImageMessage | WsCommandMessage; |
|---|