Matthias Nott
2026-03-07 8cdf33e27c633ac30e8851c4617f6063c141660d
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
5 files modified
changed files
app/chat.tsx patch | view | blame | history
components/chat/MessageList.tsx patch | view | blame | history
contexts/ChatContext.tsx patch | view | blame | history
services/audio.ts patch | view | blame | history
services/websocket.ts patch | view | blame | history
app/chat.tsx
....@@ -20,7 +20,7 @@
2020 }
2121
2222 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 } =
2424 useChat();
2525 const { status } = useConnection();
2626 const { colors, mode, cycleMode } = useTheme();
....@@ -287,7 +287,7 @@
287287 </View>
288288 </View>
289289 ) : (
290
- <MessageList messages={messages} isTyping={isTyping} onDeleteMessage={deleteMessage} />
290
+ <MessageList messages={messages} isTyping={isTyping} onDeleteMessage={deleteMessage} onLoadMore={loadMoreMessages} hasMore={hasMoreMessages} />
291291 )}
292292 </View>
293293
components/chat/MessageList.tsx
....@@ -1,5 +1,5 @@
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";
33 import { Message } from "../../types";
44 import { MessageBubble } from "./MessageBubble";
55 import { TypingIndicator } from "./TypingIndicator";
....@@ -9,62 +9,32 @@
99 messages: Message[];
1010 isTyping?: boolean;
1111 onDeleteMessage?: (id: string) => void;
12
+ onLoadMore?: () => void;
13
+ hasMore?: boolean;
1214 }
1315
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]);
4720
4821 // Play from a voice message and auto-chain all consecutive assistant voice messages after it
4922 const handlePlayVoice = useCallback(async (messageId: string) => {
5023 const idx = messages.findIndex((m) => m.id === messageId);
5124 if (idx === -1) return;
5225
53
- // Collect this message + all consecutive assistant voice messages after it
5426 const chain: Message[] = [];
5527 for (let i = idx; i < messages.length; i++) {
5628 const m = messages[i];
5729 if (m.role === "assistant" && m.type === "voice" && m.audioUri) {
5830 chain.push(m);
5931 } else if (i > idx) {
60
- // Stop at the first non-voice or non-assistant message
6132 break;
6233 }
6334 }
6435
6536 if (chain.length === 0) return;
6637
67
- // Stop current playback, then queue all chunks
6838 await stopPlayback();
6939 for (const m of chain) {
7040 playAudio(m.audioUri!);
....@@ -73,8 +43,8 @@
7343
7444 return (
7545 <FlatList
76
- ref={listRef}
77
- data={messages}
46
+ inverted
47
+ data={invertedData}
7848 keyExtractor={(item) => item.id}
7949 renderItem={({ item }) => (
8050 <MessageBubble
....@@ -83,15 +53,23 @@
8353 onPlayVoice={handlePlayVoice}
8454 />
8555 )}
86
- onContentSizeChange={handleContentSizeChange}
56
+ onEndReached={hasMore ? onLoadMore : undefined}
57
+ onEndReachedThreshold={0.5}
8758 contentContainerStyle={{ paddingVertical: 12 }}
8859 showsVerticalScrollIndicator={false}
89
- ListFooterComponent={
60
+ ListHeaderComponent={
9061 <>
9162 {isTyping && <TypingIndicator />}
9263 <View style={{ height: 4 }} />
9364 </>
9465 }
66
+ ListFooterComponent={
67
+ hasMore ? (
68
+ <View style={{ paddingVertical: 16, alignItems: "center" }}>
69
+ <ActivityIndicator size="small" />
70
+ </View>
71
+ ) : null
72
+ }
9573 />
9674 );
9775 }
contexts/ChatContext.tsx
....@@ -112,6 +112,8 @@
112112 saveTimer = setTimeout(() => persistMessages(map), 1000);
113113 }
114114
115
+const PAGE_SIZE = 50;
116
+
115117 // --- Context ---
116118
117119 interface ChatContextValue {
....@@ -131,6 +133,8 @@
131133 createSession: (opts?: { project?: string; path?: string }) => void;
132134 fetchProjects: () => void;
133135 projects: PaiProject[];
136
+ loadMoreMessages: () => void;
137
+ hasMoreMessages: boolean;
134138 unreadCounts: Record<string, number>;
135139 latestScreenshot: string | null;
136140 requestScreenshot: () => void;
....@@ -155,6 +159,8 @@
155159 const [isTyping, setIsTyping] = useState(false);
156160 // PAI projects list
157161 const [projects, setProjects] = useState<PaiProject[]>([]);
162
+ // Pagination: does the active session have more messages in storage?
163
+ const [hasMoreMessages, setHasMoreMessages] = useState(false);
158164
159165 const {
160166 status,
....@@ -184,8 +190,10 @@
184190 if (prev) {
185191 messagesMapRef.current[prev] = messages;
186192 }
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);
189197 setUnreadCounts((u) => {
190198 if (!u[active.id]) return u;
191199 const next = { ...u };
....@@ -550,6 +558,24 @@
550558 sendCommand("projects");
551559 }, [sendCommand]);
552560
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
+
553579 // --- Screenshot / navigation ---
554580 const requestScreenshot = useCallback(() => {
555581 sendCommand("screenshot");
....@@ -581,6 +607,8 @@
581607 createSession,
582608 fetchProjects,
583609 projects,
610
+ loadMoreMessages,
611
+ hasMoreMessages,
584612 unreadCounts,
585613 latestScreenshot,
586614 requestScreenshot,
services/audio.ts
....@@ -11,6 +11,22 @@
1111 durationMs: number;
1212 }
1313
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
+
1430 // --- Autoplay suppression ---
1531 // Don't autoplay voice messages when the app is in the background
1632 // or when the user is on a phone call (detected via audio interruption).
....@@ -153,7 +169,7 @@
153169 let player: ReturnType<typeof createAudioPlayer> | null = null;
154170
155171 try {
156
- await setAudioModeAsync({ playsInSilentMode: true });
172
+ await ensureAudioMode();
157173
158174 player = createAudioPlayer(uri);
159175 currentPlayer = player;
services/websocket.ts
....@@ -1,3 +1,4 @@
1
+import { AppState, AppStateStatus } from "react-native";
12 import { WsOutgoing } from "../types";
23
34 type WebSocketMessage = Record<string, unknown>;
....@@ -16,6 +17,7 @@
1617 const MAX_RECONNECT_DELAY = 30000;
1718 const RECONNECT_MULTIPLIER = 2;
1819 const LOCAL_TIMEOUT = 2500;
20
+const HEARTBEAT_INTERVAL = 20000; // 20s ping to detect zombie sockets
1921
2022 export class WebSocketClient {
2123 private ws: WebSocket | null = null;
....@@ -24,9 +26,28 @@
2426 private reconnectDelay: number = INITIAL_RECONNECT_DELAY;
2527 private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
2628 private localTimer: ReturnType<typeof setTimeout> | null = null;
29
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
30
+ private pongReceived: boolean = true;
2731 private shouldReconnect: boolean = false;
2832 private connected: boolean = false;
2933 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
+ }
3051
3152 setCallbacks(callbacks: WebSocketClientOptions) {
3253 this.callbacks = callbacks;
....@@ -45,6 +66,7 @@
4566 private cleanup() {
4667 if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; }
4768 if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
69
+ this.stopHeartbeat();
4870 if (this.ws) {
4971 const old = this.ws;
5072 this.ws = null;
....@@ -53,6 +75,43 @@
5375 old.onerror = null;
5476 old.onmessage = null;
5577 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
+ }
56115 }
57116 }
58117
....@@ -82,6 +141,7 @@
82141 this.connected = true;
83142 if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; }
84143 this.reconnectDelay = INITIAL_RECONNECT_DELAY;
144
+ this.startHeartbeat();
85145 this.callbacks.onOpen?.();
86146 };
87147
....@@ -89,6 +149,11 @@
89149 if (ws !== this.ws) return; // stale
90150 try {
91151 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
+ }
92157 this.callbacks.onMessage?.(data);
93158 } catch {
94159 this.callbacks.onMessage?.({ type: "text", content: String(event.data) });