From 8cdf33e27c633ac30e8851c4617f6063c141660d Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sat, 07 Mar 2026 17:53:05 +0100
Subject: [PATCH] fix: audio routing, WebSocket reconnection, inverted chat list

---
 services/websocket.ts           |   65 ++++++++++++++++
 services/audio.ts               |   18 ++++
 app/chat.tsx                    |    4 
 components/chat/MessageList.tsx |   62 +++++----------
 contexts/ChatContext.tsx        |   32 +++++++
 5 files changed, 134 insertions(+), 47 deletions(-)

diff --git a/app/chat.tsx b/app/chat.tsx
index 665d0f6..bbc5a4c 100644
--- a/app/chat.tsx
+++ b/app/chat.tsx
@@ -20,7 +20,7 @@
 }
 
 export default function ChatScreen() {
-  const { messages, sendTextMessage, sendVoiceMessage, sendImageMessage, deleteMessage, clearMessages, isTyping, requestScreenshot, sessions } =
+  const { messages, sendTextMessage, sendVoiceMessage, sendImageMessage, deleteMessage, clearMessages, isTyping, requestScreenshot, sessions, loadMoreMessages, hasMoreMessages } =
     useChat();
   const { status } = useConnection();
   const { colors, mode, cycleMode } = useTheme();
@@ -287,7 +287,7 @@
             </View>
           </View>
         ) : (
-          <MessageList messages={messages} isTyping={isTyping} onDeleteMessage={deleteMessage} />
+          <MessageList messages={messages} isTyping={isTyping} onDeleteMessage={deleteMessage} onLoadMore={loadMoreMessages} hasMore={hasMoreMessages} />
         )}
       </View>
 
diff --git a/components/chat/MessageList.tsx b/components/chat/MessageList.tsx
index c3eb13e..b65eb60 100644
--- a/components/chat/MessageList.tsx
+++ b/components/chat/MessageList.tsx
@@ -1,5 +1,5 @@
-import React, { useCallback, useEffect, useRef } from "react";
-import { FlatList, View } from "react-native";
+import React, { useCallback, useMemo } from "react";
+import { ActivityIndicator, FlatList, View } from "react-native";
 import { Message } from "../../types";
 import { MessageBubble } from "./MessageBubble";
 import { TypingIndicator } from "./TypingIndicator";
@@ -9,62 +9,32 @@
   messages: Message[];
   isTyping?: boolean;
   onDeleteMessage?: (id: string) => void;
+  onLoadMore?: () => void;
+  hasMore?: boolean;
 }
 
-export function MessageList({ messages, isTyping, onDeleteMessage }: MessageListProps) {
-  const listRef = useRef<FlatList<Message>>(null);
-  const prevLengthRef = useRef(0);
-
-  // Track the last message's content so transcript reflections trigger a scroll
-  const lastContent = messages.length > 0 ? messages[messages.length - 1].content : "";
-
-  // Flag: when true, every content size change triggers a scroll to bottom.
-  // Used for bulk loads (restart, session switch) where FlatList renders lazily.
-  const bulkScrollRef = useRef(false);
-
-  useEffect(() => {
-    if (messages.length > 0) {
-      const delta = Math.abs(messages.length - prevLengthRef.current);
-      if (delta > 1) {
-        // Bulk load — let onContentSizeChange handle scrolling
-        bulkScrollRef.current = true;
-        setTimeout(() => { bulkScrollRef.current = false; }, 3000);
-      } else {
-        // Single new message — smooth scroll
-        setTimeout(() => {
-          listRef.current?.scrollToEnd({ animated: true });
-        }, 50);
-      }
-    }
-    prevLengthRef.current = messages.length;
-  }, [messages.length, isTyping, lastContent]);
-
-  const handleContentSizeChange = useCallback(() => {
-    if (bulkScrollRef.current) {
-      listRef.current?.scrollToEnd({ animated: false });
-    }
-  }, []);
+export function MessageList({ messages, isTyping, onDeleteMessage, onLoadMore, hasMore }: MessageListProps) {
+  // Inverted FlatList renders bottom-up — newest messages at the bottom (visually),
+  // which means we reverse the data so index 0 = newest = rendered at bottom.
+  const invertedData = useMemo(() => [...messages].reverse(), [messages]);
 
   // Play from a voice message and auto-chain all consecutive assistant voice messages after it
   const handlePlayVoice = useCallback(async (messageId: string) => {
     const idx = messages.findIndex((m) => m.id === messageId);
     if (idx === -1) return;
 
-    // Collect this message + all consecutive assistant voice messages after it
     const chain: Message[] = [];
     for (let i = idx; i < messages.length; i++) {
       const m = messages[i];
       if (m.role === "assistant" && m.type === "voice" && m.audioUri) {
         chain.push(m);
       } else if (i > idx) {
-        // Stop at the first non-voice or non-assistant message
         break;
       }
     }
 
     if (chain.length === 0) return;
 
-    // Stop current playback, then queue all chunks
     await stopPlayback();
     for (const m of chain) {
       playAudio(m.audioUri!);
@@ -73,8 +43,8 @@
 
   return (
     <FlatList
-      ref={listRef}
-      data={messages}
+      inverted
+      data={invertedData}
       keyExtractor={(item) => item.id}
       renderItem={({ item }) => (
         <MessageBubble
@@ -83,15 +53,23 @@
           onPlayVoice={handlePlayVoice}
         />
       )}
-      onContentSizeChange={handleContentSizeChange}
+      onEndReached={hasMore ? onLoadMore : undefined}
+      onEndReachedThreshold={0.5}
       contentContainerStyle={{ paddingVertical: 12 }}
       showsVerticalScrollIndicator={false}
-      ListFooterComponent={
+      ListHeaderComponent={
         <>
           {isTyping && <TypingIndicator />}
           <View style={{ height: 4 }} />
         </>
       }
+      ListFooterComponent={
+        hasMore ? (
+          <View style={{ paddingVertical: 16, alignItems: "center" }}>
+            <ActivityIndicator size="small" />
+          </View>
+        ) : null
+      }
     />
   );
 }
diff --git a/contexts/ChatContext.tsx b/contexts/ChatContext.tsx
index 408e878..be47100 100644
--- a/contexts/ChatContext.tsx
+++ b/contexts/ChatContext.tsx
@@ -112,6 +112,8 @@
   saveTimer = setTimeout(() => persistMessages(map), 1000);
 }
 
+const PAGE_SIZE = 50;
+
 // --- Context ---
 
 interface ChatContextValue {
@@ -131,6 +133,8 @@
   createSession: (opts?: { project?: string; path?: string }) => void;
   fetchProjects: () => void;
   projects: PaiProject[];
+  loadMoreMessages: () => void;
+  hasMoreMessages: boolean;
   unreadCounts: Record<string, number>;
   latestScreenshot: string | null;
   requestScreenshot: () => void;
@@ -155,6 +159,8 @@
   const [isTyping, setIsTyping] = useState(false);
   // PAI projects list
   const [projects, setProjects] = useState<PaiProject[]>([]);
+  // Pagination: does the active session have more messages in storage?
+  const [hasMoreMessages, setHasMoreMessages] = useState(false);
 
   const {
     status,
@@ -184,8 +190,10 @@
           if (prev) {
             messagesMapRef.current[prev] = messages;
           }
-          const stored = messagesMapRef.current[active.id] ?? [];
-          setMessages(stored);
+          const all = messagesMapRef.current[active.id] ?? [];
+          const page = all.length > PAGE_SIZE ? all.slice(-PAGE_SIZE) : all;
+          setMessages(page);
+          setHasMoreMessages(all.length > PAGE_SIZE);
           setUnreadCounts((u) => {
             if (!u[active.id]) return u;
             const next = { ...u };
@@ -550,6 +558,24 @@
     sendCommand("projects");
   }, [sendCommand]);
 
+  const loadMoreMessages = useCallback(() => {
+    setActiveSessionId((sessId) => {
+      if (!sessId) return sessId;
+      const all = messagesMapRef.current[sessId] ?? [];
+      setMessages((current) => {
+        if (current.length >= all.length) {
+          setHasMoreMessages(false);
+          return current;
+        }
+        const nextSize = Math.min(current.length + PAGE_SIZE, all.length);
+        const page = all.slice(-nextSize);
+        setHasMoreMessages(nextSize < all.length);
+        return page;
+      });
+      return sessId;
+    });
+  }, []);
+
   // --- Screenshot / navigation ---
   const requestScreenshot = useCallback(() => {
     sendCommand("screenshot");
@@ -581,6 +607,8 @@
         createSession,
         fetchProjects,
         projects,
+        loadMoreMessages,
+        hasMoreMessages,
         unreadCounts,
         latestScreenshot,
         requestScreenshot,
diff --git a/services/audio.ts b/services/audio.ts
index 188164e..fc7099c 100644
--- a/services/audio.ts
+++ b/services/audio.ts
@@ -11,6 +11,22 @@
   durationMs: number;
 }
 
+// --- Audio mode (set once at import time) ---
+// Setting this on every playback causes iOS to renegotiate the audio route,
+// which disconnects Bluetooth/CarPlay. Set it once and leave it alone.
+let _audioModeReady = false;
+async function ensureAudioMode(): Promise<void> {
+  if (_audioModeReady) return;
+  _audioModeReady = true;
+  try {
+    await setAudioModeAsync({ playsInSilentMode: true });
+  } catch {
+    _audioModeReady = false; // retry next time
+  }
+}
+// Eagerly initialize on module load
+ensureAudioMode();
+
 // --- Autoplay suppression ---
 // Don't autoplay voice messages when the app is in the background
 // or when the user is on a phone call (detected via audio interruption).
@@ -153,7 +169,7 @@
     let player: ReturnType<typeof createAudioPlayer> | null = null;
 
     try {
-      await setAudioModeAsync({ playsInSilentMode: true });
+      await ensureAudioMode();
 
       player = createAudioPlayer(uri);
       currentPlayer = player;
diff --git a/services/websocket.ts b/services/websocket.ts
index 03f1a59..4c74e56 100644
--- a/services/websocket.ts
+++ b/services/websocket.ts
@@ -1,3 +1,4 @@
+import { AppState, AppStateStatus } from "react-native";
 import { WsOutgoing } from "../types";
 
 type WebSocketMessage = Record<string, unknown>;
@@ -16,6 +17,7 @@
 const MAX_RECONNECT_DELAY = 30000;
 const RECONNECT_MULTIPLIER = 2;
 const LOCAL_TIMEOUT = 2500;
+const HEARTBEAT_INTERVAL = 20000; // 20s ping to detect zombie sockets
 
 export class WebSocketClient {
   private ws: WebSocket | null = null;
@@ -24,9 +26,28 @@
   private reconnectDelay: number = INITIAL_RECONNECT_DELAY;
   private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
   private localTimer: ReturnType<typeof setTimeout> | null = null;
+  private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
+  private pongReceived: boolean = true;
   private shouldReconnect: boolean = false;
   private connected: boolean = false;
   private callbacks: WebSocketClientOptions = {};
+
+  constructor() {
+    // When app comes back to foreground, check if socket is still alive
+    AppState.addEventListener("change", (state: AppStateStatus) => {
+      if (state === "active" && this.shouldReconnect) {
+        if (!this.connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
+          // Socket is dead — force immediate reconnect
+          this.reconnectDelay = INITIAL_RECONNECT_DELAY;
+          this.urlIndex = 0;
+          this.tryUrl();
+        } else {
+          // Socket looks open but might be zombie — send a ping to verify
+          this.sendPing();
+        }
+      }
+    });
+  }
 
   setCallbacks(callbacks: WebSocketClientOptions) {
     this.callbacks = callbacks;
@@ -45,6 +66,7 @@
   private cleanup() {
     if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; }
     if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
+    this.stopHeartbeat();
     if (this.ws) {
       const old = this.ws;
       this.ws = null;
@@ -53,6 +75,43 @@
       old.onerror = null;
       old.onmessage = null;
       try { old.close(); } catch { /* ignore */ }
+    }
+  }
+
+  private startHeartbeat() {
+    this.stopHeartbeat();
+    this.pongReceived = true;
+    this.heartbeatTimer = setInterval(() => {
+      if (!this.pongReceived) {
+        // No pong since last ping — socket is zombie, force reconnect
+        this.connected = false;
+        this.callbacks.onClose?.();
+        this.reconnectDelay = INITIAL_RECONNECT_DELAY;
+        this.urlIndex = 0;
+        this.tryUrl();
+        return;
+      }
+      this.sendPing();
+    }, HEARTBEAT_INTERVAL);
+  }
+
+  private stopHeartbeat() {
+    if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
+  }
+
+  private sendPing() {
+    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
+      this.pongReceived = false;
+      try {
+        this.ws.send(JSON.stringify({ type: "ping" }));
+      } catch {
+        // Send failed — socket is dead
+        this.connected = false;
+        this.callbacks.onClose?.();
+        this.reconnectDelay = INITIAL_RECONNECT_DELAY;
+        this.urlIndex = 0;
+        this.tryUrl();
+      }
     }
   }
 
@@ -82,6 +141,7 @@
       this.connected = true;
       if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; }
       this.reconnectDelay = INITIAL_RECONNECT_DELAY;
+      this.startHeartbeat();
       this.callbacks.onOpen?.();
     };
 
@@ -89,6 +149,11 @@
       if (ws !== this.ws) return; // stale
       try {
         const data = JSON.parse(event.data) as WebSocketMessage;
+        // Handle pong responses from heartbeat
+        if (data.type === "pong") {
+          this.pongReceived = true;
+          return;
+        }
         this.callbacks.onMessage?.(data);
       } catch {
         this.callbacks.onMessage?.({ type: "text", content: String(event.data) });

--
Gitblit v1.3.1