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