fix: audio routing, WebSocket reconnection, inverted chat list
- Set audio mode once at startup instead of per-playback (fixes Bluetooth/CarPlay disconnect)
- Add AppState listener to force WebSocket reconnect on foreground resume
- Add 20s heartbeat ping/pong to detect zombie sockets after network switch
- Rewrite MessageList with inverted FlatList (WhatsApp-style bottom-anchored)
- Add reverse pagination: load last 50 messages, pull more on scroll up
- Wire loadMoreMessages/hasMoreMessages from ChatContext into chat screen
| .. | .. |
|---|
| 20 | 20 | } |
|---|
| 21 | 21 | |
|---|
| 22 | 22 | export default function ChatScreen() { |
|---|
| 23 | | - const { messages, sendTextMessage, sendVoiceMessage, sendImageMessage, deleteMessage, clearMessages, isTyping, requestScreenshot, sessions } = |
|---|
| 23 | + const { messages, sendTextMessage, sendVoiceMessage, sendImageMessage, deleteMessage, clearMessages, isTyping, requestScreenshot, sessions, loadMoreMessages, hasMoreMessages } = |
|---|
| 24 | 24 | useChat(); |
|---|
| 25 | 25 | const { status } = useConnection(); |
|---|
| 26 | 26 | const { colors, mode, cycleMode } = useTheme(); |
|---|
| .. | .. |
|---|
| 287 | 287 | </View> |
|---|
| 288 | 288 | </View> |
|---|
| 289 | 289 | ) : ( |
|---|
| 290 | | - <MessageList messages={messages} isTyping={isTyping} onDeleteMessage={deleteMessage} /> |
|---|
| 290 | + <MessageList messages={messages} isTyping={isTyping} onDeleteMessage={deleteMessage} onLoadMore={loadMoreMessages} hasMore={hasMoreMessages} /> |
|---|
| 291 | 291 | )} |
|---|
| 292 | 292 | </View> |
|---|
| 293 | 293 | |
|---|
| .. | .. |
|---|
| 1 | | -import React, { useCallback, useEffect, useRef } from "react"; |
|---|
| 2 | | -import { FlatList, View } from "react-native"; |
|---|
| 1 | +import React, { useCallback, useMemo } from "react"; |
|---|
| 2 | +import { ActivityIndicator, FlatList, View } from "react-native"; |
|---|
| 3 | 3 | import { Message } from "../../types"; |
|---|
| 4 | 4 | import { MessageBubble } from "./MessageBubble"; |
|---|
| 5 | 5 | import { TypingIndicator } from "./TypingIndicator"; |
|---|
| .. | .. |
|---|
| 9 | 9 | messages: Message[]; |
|---|
| 10 | 10 | isTyping?: boolean; |
|---|
| 11 | 11 | onDeleteMessage?: (id: string) => void; |
|---|
| 12 | + onLoadMore?: () => void; |
|---|
| 13 | + hasMore?: boolean; |
|---|
| 12 | 14 | } |
|---|
| 13 | 15 | |
|---|
| 14 | | -export function MessageList({ messages, isTyping, onDeleteMessage }: MessageListProps) { |
|---|
| 15 | | - const listRef = useRef<FlatList<Message>>(null); |
|---|
| 16 | | - const prevLengthRef = useRef(0); |
|---|
| 17 | | - |
|---|
| 18 | | - // Track the last message's content so transcript reflections trigger a scroll |
|---|
| 19 | | - const lastContent = messages.length > 0 ? messages[messages.length - 1].content : ""; |
|---|
| 20 | | - |
|---|
| 21 | | - // Flag: when true, every content size change triggers a scroll to bottom. |
|---|
| 22 | | - // Used for bulk loads (restart, session switch) where FlatList renders lazily. |
|---|
| 23 | | - const bulkScrollRef = useRef(false); |
|---|
| 24 | | - |
|---|
| 25 | | - useEffect(() => { |
|---|
| 26 | | - if (messages.length > 0) { |
|---|
| 27 | | - const delta = Math.abs(messages.length - prevLengthRef.current); |
|---|
| 28 | | - if (delta > 1) { |
|---|
| 29 | | - // Bulk load — let onContentSizeChange handle scrolling |
|---|
| 30 | | - bulkScrollRef.current = true; |
|---|
| 31 | | - setTimeout(() => { bulkScrollRef.current = false; }, 3000); |
|---|
| 32 | | - } else { |
|---|
| 33 | | - // Single new message — smooth scroll |
|---|
| 34 | | - setTimeout(() => { |
|---|
| 35 | | - listRef.current?.scrollToEnd({ animated: true }); |
|---|
| 36 | | - }, 50); |
|---|
| 37 | | - } |
|---|
| 38 | | - } |
|---|
| 39 | | - prevLengthRef.current = messages.length; |
|---|
| 40 | | - }, [messages.length, isTyping, lastContent]); |
|---|
| 41 | | - |
|---|
| 42 | | - const handleContentSizeChange = useCallback(() => { |
|---|
| 43 | | - if (bulkScrollRef.current) { |
|---|
| 44 | | - listRef.current?.scrollToEnd({ animated: false }); |
|---|
| 45 | | - } |
|---|
| 46 | | - }, []); |
|---|
| 16 | +export function MessageList({ messages, isTyping, onDeleteMessage, onLoadMore, hasMore }: MessageListProps) { |
|---|
| 17 | + // Inverted FlatList renders bottom-up — newest messages at the bottom (visually), |
|---|
| 18 | + // which means we reverse the data so index 0 = newest = rendered at bottom. |
|---|
| 19 | + const invertedData = useMemo(() => [...messages].reverse(), [messages]); |
|---|
| 47 | 20 | |
|---|
| 48 | 21 | // Play from a voice message and auto-chain all consecutive assistant voice messages after it |
|---|
| 49 | 22 | const handlePlayVoice = useCallback(async (messageId: string) => { |
|---|
| 50 | 23 | const idx = messages.findIndex((m) => m.id === messageId); |
|---|
| 51 | 24 | if (idx === -1) return; |
|---|
| 52 | 25 | |
|---|
| 53 | | - // Collect this message + all consecutive assistant voice messages after it |
|---|
| 54 | 26 | const chain: Message[] = []; |
|---|
| 55 | 27 | for (let i = idx; i < messages.length; i++) { |
|---|
| 56 | 28 | const m = messages[i]; |
|---|
| 57 | 29 | if (m.role === "assistant" && m.type === "voice" && m.audioUri) { |
|---|
| 58 | 30 | chain.push(m); |
|---|
| 59 | 31 | } else if (i > idx) { |
|---|
| 60 | | - // Stop at the first non-voice or non-assistant message |
|---|
| 61 | 32 | break; |
|---|
| 62 | 33 | } |
|---|
| 63 | 34 | } |
|---|
| 64 | 35 | |
|---|
| 65 | 36 | if (chain.length === 0) return; |
|---|
| 66 | 37 | |
|---|
| 67 | | - // Stop current playback, then queue all chunks |
|---|
| 68 | 38 | await stopPlayback(); |
|---|
| 69 | 39 | for (const m of chain) { |
|---|
| 70 | 40 | playAudio(m.audioUri!); |
|---|
| .. | .. |
|---|
| 73 | 43 | |
|---|
| 74 | 44 | return ( |
|---|
| 75 | 45 | <FlatList |
|---|
| 76 | | - ref={listRef} |
|---|
| 77 | | - data={messages} |
|---|
| 46 | + inverted |
|---|
| 47 | + data={invertedData} |
|---|
| 78 | 48 | keyExtractor={(item) => item.id} |
|---|
| 79 | 49 | renderItem={({ item }) => ( |
|---|
| 80 | 50 | <MessageBubble |
|---|
| .. | .. |
|---|
| 83 | 53 | onPlayVoice={handlePlayVoice} |
|---|
| 84 | 54 | /> |
|---|
| 85 | 55 | )} |
|---|
| 86 | | - onContentSizeChange={handleContentSizeChange} |
|---|
| 56 | + onEndReached={hasMore ? onLoadMore : undefined} |
|---|
| 57 | + onEndReachedThreshold={0.5} |
|---|
| 87 | 58 | contentContainerStyle={{ paddingVertical: 12 }} |
|---|
| 88 | 59 | showsVerticalScrollIndicator={false} |
|---|
| 89 | | - ListFooterComponent={ |
|---|
| 60 | + ListHeaderComponent={ |
|---|
| 90 | 61 | <> |
|---|
| 91 | 62 | {isTyping && <TypingIndicator />} |
|---|
| 92 | 63 | <View style={{ height: 4 }} /> |
|---|
| 93 | 64 | </> |
|---|
| 94 | 65 | } |
|---|
| 66 | + ListFooterComponent={ |
|---|
| 67 | + hasMore ? ( |
|---|
| 68 | + <View style={{ paddingVertical: 16, alignItems: "center" }}> |
|---|
| 69 | + <ActivityIndicator size="small" /> |
|---|
| 70 | + </View> |
|---|
| 71 | + ) : null |
|---|
| 72 | + } |
|---|
| 95 | 73 | /> |
|---|
| 96 | 74 | ); |
|---|
| 97 | 75 | } |
|---|
| .. | .. |
|---|
| 112 | 112 | saveTimer = setTimeout(() => persistMessages(map), 1000); |
|---|
| 113 | 113 | } |
|---|
| 114 | 114 | |
|---|
| 115 | +const PAGE_SIZE = 50; |
|---|
| 116 | + |
|---|
| 115 | 117 | // --- Context --- |
|---|
| 116 | 118 | |
|---|
| 117 | 119 | interface ChatContextValue { |
|---|
| .. | .. |
|---|
| 131 | 133 | createSession: (opts?: { project?: string; path?: string }) => void; |
|---|
| 132 | 134 | fetchProjects: () => void; |
|---|
| 133 | 135 | projects: PaiProject[]; |
|---|
| 136 | + loadMoreMessages: () => void; |
|---|
| 137 | + hasMoreMessages: boolean; |
|---|
| 134 | 138 | unreadCounts: Record<string, number>; |
|---|
| 135 | 139 | latestScreenshot: string | null; |
|---|
| 136 | 140 | requestScreenshot: () => void; |
|---|
| .. | .. |
|---|
| 155 | 159 | const [isTyping, setIsTyping] = useState(false); |
|---|
| 156 | 160 | // PAI projects list |
|---|
| 157 | 161 | const [projects, setProjects] = useState<PaiProject[]>([]); |
|---|
| 162 | + // Pagination: does the active session have more messages in storage? |
|---|
| 163 | + const [hasMoreMessages, setHasMoreMessages] = useState(false); |
|---|
| 158 | 164 | |
|---|
| 159 | 165 | const { |
|---|
| 160 | 166 | status, |
|---|
| .. | .. |
|---|
| 184 | 190 | if (prev) { |
|---|
| 185 | 191 | messagesMapRef.current[prev] = messages; |
|---|
| 186 | 192 | } |
|---|
| 187 | | - const stored = messagesMapRef.current[active.id] ?? []; |
|---|
| 188 | | - setMessages(stored); |
|---|
| 193 | + const all = messagesMapRef.current[active.id] ?? []; |
|---|
| 194 | + const page = all.length > PAGE_SIZE ? all.slice(-PAGE_SIZE) : all; |
|---|
| 195 | + setMessages(page); |
|---|
| 196 | + setHasMoreMessages(all.length > PAGE_SIZE); |
|---|
| 189 | 197 | setUnreadCounts((u) => { |
|---|
| 190 | 198 | if (!u[active.id]) return u; |
|---|
| 191 | 199 | const next = { ...u }; |
|---|
| .. | .. |
|---|
| 550 | 558 | sendCommand("projects"); |
|---|
| 551 | 559 | }, [sendCommand]); |
|---|
| 552 | 560 | |
|---|
| 561 | + const loadMoreMessages = useCallback(() => { |
|---|
| 562 | + setActiveSessionId((sessId) => { |
|---|
| 563 | + if (!sessId) return sessId; |
|---|
| 564 | + const all = messagesMapRef.current[sessId] ?? []; |
|---|
| 565 | + setMessages((current) => { |
|---|
| 566 | + if (current.length >= all.length) { |
|---|
| 567 | + setHasMoreMessages(false); |
|---|
| 568 | + return current; |
|---|
| 569 | + } |
|---|
| 570 | + const nextSize = Math.min(current.length + PAGE_SIZE, all.length); |
|---|
| 571 | + const page = all.slice(-nextSize); |
|---|
| 572 | + setHasMoreMessages(nextSize < all.length); |
|---|
| 573 | + return page; |
|---|
| 574 | + }); |
|---|
| 575 | + return sessId; |
|---|
| 576 | + }); |
|---|
| 577 | + }, []); |
|---|
| 578 | + |
|---|
| 553 | 579 | // --- Screenshot / navigation --- |
|---|
| 554 | 580 | const requestScreenshot = useCallback(() => { |
|---|
| 555 | 581 | sendCommand("screenshot"); |
|---|
| .. | .. |
|---|
| 581 | 607 | createSession, |
|---|
| 582 | 608 | fetchProjects, |
|---|
| 583 | 609 | projects, |
|---|
| 610 | + loadMoreMessages, |
|---|
| 611 | + hasMoreMessages, |
|---|
| 584 | 612 | unreadCounts, |
|---|
| 585 | 613 | latestScreenshot, |
|---|
| 586 | 614 | requestScreenshot, |
|---|
| .. | .. |
|---|
| 11 | 11 | durationMs: number; |
|---|
| 12 | 12 | } |
|---|
| 13 | 13 | |
|---|
| 14 | +// --- Audio mode (set once at import time) --- |
|---|
| 15 | +// Setting this on every playback causes iOS to renegotiate the audio route, |
|---|
| 16 | +// which disconnects Bluetooth/CarPlay. Set it once and leave it alone. |
|---|
| 17 | +let _audioModeReady = false; |
|---|
| 18 | +async function ensureAudioMode(): Promise<void> { |
|---|
| 19 | + if (_audioModeReady) return; |
|---|
| 20 | + _audioModeReady = true; |
|---|
| 21 | + try { |
|---|
| 22 | + await setAudioModeAsync({ playsInSilentMode: true }); |
|---|
| 23 | + } catch { |
|---|
| 24 | + _audioModeReady = false; // retry next time |
|---|
| 25 | + } |
|---|
| 26 | +} |
|---|
| 27 | +// Eagerly initialize on module load |
|---|
| 28 | +ensureAudioMode(); |
|---|
| 29 | + |
|---|
| 14 | 30 | // --- Autoplay suppression --- |
|---|
| 15 | 31 | // Don't autoplay voice messages when the app is in the background |
|---|
| 16 | 32 | // or when the user is on a phone call (detected via audio interruption). |
|---|
| .. | .. |
|---|
| 153 | 169 | let player: ReturnType<typeof createAudioPlayer> | null = null; |
|---|
| 154 | 170 | |
|---|
| 155 | 171 | try { |
|---|
| 156 | | - await setAudioModeAsync({ playsInSilentMode: true }); |
|---|
| 172 | + await ensureAudioMode(); |
|---|
| 157 | 173 | |
|---|
| 158 | 174 | player = createAudioPlayer(uri); |
|---|
| 159 | 175 | currentPlayer = player; |
|---|
| .. | .. |
|---|
| 1 | +import { AppState, AppStateStatus } from "react-native"; |
|---|
| 1 | 2 | import { WsOutgoing } from "../types"; |
|---|
| 2 | 3 | |
|---|
| 3 | 4 | type WebSocketMessage = Record<string, unknown>; |
|---|
| .. | .. |
|---|
| 16 | 17 | const MAX_RECONNECT_DELAY = 30000; |
|---|
| 17 | 18 | const RECONNECT_MULTIPLIER = 2; |
|---|
| 18 | 19 | const LOCAL_TIMEOUT = 2500; |
|---|
| 20 | +const HEARTBEAT_INTERVAL = 20000; // 20s ping to detect zombie sockets |
|---|
| 19 | 21 | |
|---|
| 20 | 22 | export class WebSocketClient { |
|---|
| 21 | 23 | private ws: WebSocket | null = null; |
|---|
| .. | .. |
|---|
| 24 | 26 | private reconnectDelay: number = INITIAL_RECONNECT_DELAY; |
|---|
| 25 | 27 | private reconnectTimer: ReturnType<typeof setTimeout> | null = null; |
|---|
| 26 | 28 | private localTimer: ReturnType<typeof setTimeout> | null = null; |
|---|
| 29 | + private heartbeatTimer: ReturnType<typeof setInterval> | null = null; |
|---|
| 30 | + private pongReceived: boolean = true; |
|---|
| 27 | 31 | private shouldReconnect: boolean = false; |
|---|
| 28 | 32 | private connected: boolean = false; |
|---|
| 29 | 33 | private callbacks: WebSocketClientOptions = {}; |
|---|
| 34 | + |
|---|
| 35 | + constructor() { |
|---|
| 36 | + // When app comes back to foreground, check if socket is still alive |
|---|
| 37 | + AppState.addEventListener("change", (state: AppStateStatus) => { |
|---|
| 38 | + if (state === "active" && this.shouldReconnect) { |
|---|
| 39 | + if (!this.connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) { |
|---|
| 40 | + // Socket is dead — force immediate reconnect |
|---|
| 41 | + this.reconnectDelay = INITIAL_RECONNECT_DELAY; |
|---|
| 42 | + this.urlIndex = 0; |
|---|
| 43 | + this.tryUrl(); |
|---|
| 44 | + } else { |
|---|
| 45 | + // Socket looks open but might be zombie — send a ping to verify |
|---|
| 46 | + this.sendPing(); |
|---|
| 47 | + } |
|---|
| 48 | + } |
|---|
| 49 | + }); |
|---|
| 50 | + } |
|---|
| 30 | 51 | |
|---|
| 31 | 52 | setCallbacks(callbacks: WebSocketClientOptions) { |
|---|
| 32 | 53 | this.callbacks = callbacks; |
|---|
| .. | .. |
|---|
| 45 | 66 | private cleanup() { |
|---|
| 46 | 67 | if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; } |
|---|
| 47 | 68 | if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } |
|---|
| 69 | + this.stopHeartbeat(); |
|---|
| 48 | 70 | if (this.ws) { |
|---|
| 49 | 71 | const old = this.ws; |
|---|
| 50 | 72 | this.ws = null; |
|---|
| .. | .. |
|---|
| 53 | 75 | old.onerror = null; |
|---|
| 54 | 76 | old.onmessage = null; |
|---|
| 55 | 77 | try { old.close(); } catch { /* ignore */ } |
|---|
| 78 | + } |
|---|
| 79 | + } |
|---|
| 80 | + |
|---|
| 81 | + private startHeartbeat() { |
|---|
| 82 | + this.stopHeartbeat(); |
|---|
| 83 | + this.pongReceived = true; |
|---|
| 84 | + this.heartbeatTimer = setInterval(() => { |
|---|
| 85 | + if (!this.pongReceived) { |
|---|
| 86 | + // No pong since last ping — socket is zombie, force reconnect |
|---|
| 87 | + this.connected = false; |
|---|
| 88 | + this.callbacks.onClose?.(); |
|---|
| 89 | + this.reconnectDelay = INITIAL_RECONNECT_DELAY; |
|---|
| 90 | + this.urlIndex = 0; |
|---|
| 91 | + this.tryUrl(); |
|---|
| 92 | + return; |
|---|
| 93 | + } |
|---|
| 94 | + this.sendPing(); |
|---|
| 95 | + }, HEARTBEAT_INTERVAL); |
|---|
| 96 | + } |
|---|
| 97 | + |
|---|
| 98 | + private stopHeartbeat() { |
|---|
| 99 | + if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } |
|---|
| 100 | + } |
|---|
| 101 | + |
|---|
| 102 | + private sendPing() { |
|---|
| 103 | + if (this.ws && this.ws.readyState === WebSocket.OPEN) { |
|---|
| 104 | + this.pongReceived = false; |
|---|
| 105 | + try { |
|---|
| 106 | + this.ws.send(JSON.stringify({ type: "ping" })); |
|---|
| 107 | + } catch { |
|---|
| 108 | + // Send failed — socket is dead |
|---|
| 109 | + this.connected = false; |
|---|
| 110 | + this.callbacks.onClose?.(); |
|---|
| 111 | + this.reconnectDelay = INITIAL_RECONNECT_DELAY; |
|---|
| 112 | + this.urlIndex = 0; |
|---|
| 113 | + this.tryUrl(); |
|---|
| 114 | + } |
|---|
| 56 | 115 | } |
|---|
| 57 | 116 | } |
|---|
| 58 | 117 | |
|---|
| .. | .. |
|---|
| 82 | 141 | this.connected = true; |
|---|
| 83 | 142 | if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; } |
|---|
| 84 | 143 | this.reconnectDelay = INITIAL_RECONNECT_DELAY; |
|---|
| 144 | + this.startHeartbeat(); |
|---|
| 85 | 145 | this.callbacks.onOpen?.(); |
|---|
| 86 | 146 | }; |
|---|
| 87 | 147 | |
|---|
| .. | .. |
|---|
| 89 | 149 | if (ws !== this.ws) return; // stale |
|---|
| 90 | 150 | try { |
|---|
| 91 | 151 | const data = JSON.parse(event.data) as WebSocketMessage; |
|---|
| 152 | + // Handle pong responses from heartbeat |
|---|
| 153 | + if (data.type === "pong") { |
|---|
| 154 | + this.pongReceived = true; |
|---|
| 155 | + return; |
|---|
| 156 | + } |
|---|
| 92 | 157 | this.callbacks.onMessage?.(data); |
|---|
| 93 | 158 | } catch { |
|---|
| 94 | 159 | this.callbacks.onMessage?.({ type: "text", content: String(event.data) }); |
|---|