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
| .. | .. |
|---|
| 10 | 10 | import { CommandBar, TextModeCommandBar } from "../components/chat/CommandBar"; |
|---|
| 11 | 11 | import { ImageCaptionModal } from "../components/chat/ImageCaptionModal"; |
|---|
| 12 | 12 | import { StatusDot } from "../components/ui/StatusDot"; |
|---|
| 13 | +import { IncomingToast } from "../components/ui/IncomingToast"; |
|---|
| 13 | 14 | import { SessionDrawer } from "../components/SessionDrawer"; |
|---|
| 14 | 15 | import { playAudio, stopPlayback, isPlaying, onPlayingChange } from "../services/audio"; |
|---|
| 15 | 16 | |
|---|
| .. | .. |
|---|
| 20 | 21 | } |
|---|
| 21 | 22 | |
|---|
| 22 | 23 | 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 } = |
|---|
| 24 | 25 | useChat(); |
|---|
| 25 | 26 | const { status } = useConnection(); |
|---|
| 26 | 27 | const { colors, mode, cycleMode } = useTheme(); |
|---|
| .. | .. |
|---|
| 34 | 35 | useEffect(() => { |
|---|
| 35 | 36 | return onPlayingChange((uri) => setAudioPlaying(uri !== null)); |
|---|
| 36 | 37 | }, []); |
|---|
| 38 | + |
|---|
| 39 | + const handleToastTap = useCallback(() => { |
|---|
| 40 | + if (incomingToast) { |
|---|
| 41 | + switchSession(incomingToast.sessionId); |
|---|
| 42 | + dismissToast(); |
|---|
| 43 | + } |
|---|
| 44 | + }, [incomingToast, switchSession, dismissToast]); |
|---|
| 37 | 45 | |
|---|
| 38 | 46 | const handleScreenshot = useCallback(() => { |
|---|
| 39 | 47 | requestScreenshot(); |
|---|
| .. | .. |
|---|
| 252 | 260 | </View> |
|---|
| 253 | 261 | |
|---|
| 254 | 262 | {/* 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 | + )} |
|---|
| 256 | 273 | {messages.length === 0 ? ( |
|---|
| 257 | 274 | <View style={{ flex: 1, alignItems: "center", justifyContent: "center", gap: 16 }}> |
|---|
| 258 | 275 | <View |
|---|
| .. | .. |
|---|
| 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 | +} |
|---|
| .. | .. |
|---|
| 116 | 116 | |
|---|
| 117 | 117 | // --- Context --- |
|---|
| 118 | 118 | |
|---|
| 119 | +interface IncomingToast { |
|---|
| 120 | + sessionId: string; |
|---|
| 121 | + sessionName: string; |
|---|
| 122 | + preview: string; |
|---|
| 123 | +} |
|---|
| 124 | + |
|---|
| 119 | 125 | interface ChatContextValue { |
|---|
| 120 | 126 | messages: Message[]; |
|---|
| 121 | 127 | sendTextMessage: (text: string) => void; |
|---|
| .. | .. |
|---|
| 136 | 142 | loadMoreMessages: () => void; |
|---|
| 137 | 143 | hasMoreMessages: boolean; |
|---|
| 138 | 144 | unreadCounts: Record<string, number>; |
|---|
| 145 | + incomingToast: IncomingToast | null; |
|---|
| 146 | + dismissToast: () => void; |
|---|
| 139 | 147 | latestScreenshot: string | null; |
|---|
| 140 | 148 | requestScreenshot: () => void; |
|---|
| 141 | 149 | sendNavKey: (key: string) => void; |
|---|
| .. | .. |
|---|
| 156 | 164 | const [messages, setMessages] = useState<Message[]>([]); |
|---|
| 157 | 165 | // Unread counts for non-active sessions |
|---|
| 158 | 166 | 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>>({}); |
|---|
| 160 | 169 | 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); |
|---|
| 161 | 172 | // PAI projects list |
|---|
| 162 | 173 | const [projects, setProjects] = useState<PaiProject[]>([]); |
|---|
| 163 | 174 | // Pagination: does the active session have more messages in storage? |
|---|
| .. | .. |
|---|
| 199 | 210 | delete next[active.id]; |
|---|
| 200 | 211 | return next; |
|---|
| 201 | 212 | }); |
|---|
| 213 | + // Sync typing indicator for the new active session |
|---|
| 214 | + const activeTyping = typingMapRef.current[active.id] ?? false; |
|---|
| 215 | + setIsTyping(activeTyping); |
|---|
| 202 | 216 | } |
|---|
| 203 | 217 | activeSessionIdRef.current = active.id; |
|---|
| 204 | 218 | return active.id; |
|---|
| .. | .. |
|---|
| 251 | 265 | ...u, |
|---|
| 252 | 266 | [sessionId]: (u[sessionId] ?? 0) + 1, |
|---|
| 253 | 267 | })); |
|---|
| 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 | + } |
|---|
| 254 | 278 | } |
|---|
| 255 | 279 | }, []); |
|---|
| 256 | 280 | |
|---|
| .. | .. |
|---|
| 359 | 383 | break; |
|---|
| 360 | 384 | } |
|---|
| 361 | 385 | 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 |
|---|
| 370 | 387 | sendCommand("sessions"); |
|---|
| 371 | 388 | break; |
|---|
| 372 | 389 | } |
|---|
| 373 | 390 | 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 |
|---|
| 382 | 392 | sendCommand("sessions"); |
|---|
| 383 | 393 | break; |
|---|
| 384 | 394 | } |
|---|
| .. | .. |
|---|
| 388 | 398 | break; |
|---|
| 389 | 399 | } |
|---|
| 390 | 400 | 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); |
|---|
| 392 | 406 | break; |
|---|
| 393 | 407 | } |
|---|
| 394 | 408 | case "status": { |
|---|
| .. | .. |
|---|
| 545 | 559 | sendCommand("projects"); |
|---|
| 546 | 560 | }, [sendCommand]); |
|---|
| 547 | 561 | |
|---|
| 562 | + const dismissToast = useCallback(() => { |
|---|
| 563 | + setIncomingToast(null); |
|---|
| 564 | + }, []); |
|---|
| 565 | + |
|---|
| 548 | 566 | const loadMoreMessages = useCallback(() => { |
|---|
| 549 | 567 | const sessId = activeSessionIdRef.current; |
|---|
| 550 | 568 | if (!sessId) return; |
|---|
| .. | .. |
|---|
| 595 | 613 | loadMoreMessages, |
|---|
| 596 | 614 | hasMoreMessages, |
|---|
| 597 | 615 | unreadCounts, |
|---|
| 616 | + incomingToast, |
|---|
| 617 | + dismissToast, |
|---|
| 598 | 618 | latestScreenshot, |
|---|
| 599 | 619 | requestScreenshot, |
|---|
| 600 | 620 | sendNavKey, |
|---|
| .. | .. |
|---|
| 108 | 108 | export interface WsIncomingTyping { |
|---|
| 109 | 109 | type: "typing"; |
|---|
| 110 | 110 | typing: boolean; |
|---|
| 111 | + sessionId?: string; |
|---|
| 111 | 112 | } |
|---|
| 112 | 113 | |
|---|
| 113 | 114 | export interface WsIncomingError { |
|---|