From 90fc31a938afac0f6910c7947c6ecba0adebfea4 Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Mon, 06 Apr 2026 14:12:23 +0200
Subject: [PATCH] fix: immediate disk writes, notification tap skip same session, catch_up trace logging, resume simplification

---
 lib/services/message_store.dart |   14 +++++++++++---
 lib/providers/providers.dart    |   10 +++++++++-
 lib/screens/chat_screen.dart    |   29 +++++++++++++++--------------
 3 files changed, 35 insertions(+), 18 deletions(-)

diff --git a/lib/providers/providers.dart b/lib/providers/providers.dart
index 63cf5a4..ad4259a 100644
--- a/lib/providers/providers.dart
+++ b/lib/providers/providers.dart
@@ -9,6 +9,7 @@
 import '../models/server_config.dart';
 import '../models/session.dart';
 import '../services/message_store.dart';
+import '../services/trace_service.dart';
 import '../services/mqtt_service.dart' show ConnectionStatus;
 import '../services/navigate_notifier.dart';
 
@@ -99,6 +100,12 @@
 
   /// Switch to a new session and load its messages.
   Future<void> switchSession(String sessionId) async {
+    // Log caller for debugging
+    final trace = StackTrace.current.toString().split('\n').take(4).join(' | ');
+    TraceService.instance.addTrace(
+      'switchSession',
+      'from=${_currentSessionId?.substring(0, 8) ?? "null"}(${state.length}) → ${sessionId.substring(0, 8)} | $trace',
+    );
     // Write current session DIRECTLY to disk (no debounce — prevents data loss)
     if (_currentSessionId != null && state.isNotEmpty) {
       await MessageStore.writeDirect(_currentSessionId!, state);
@@ -113,7 +120,8 @@
   void addMessage(Message message) {
     state = [...state, message];
     if (_currentSessionId != null) {
-      MessageStore.save(_currentSessionId!, state);
+      // Write immediately (not debounced) to prevent race with switchSession's loadAll
+      MessageStore.writeDirect(_currentSessionId!, state);
     }
   }
 
diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart
index e470ce7..e889469 100644
--- a/lib/screens/chat_screen.dart
+++ b/lib/screens/chat_screen.dart
@@ -223,19 +223,18 @@
       // Re-register APNs token after reconnect so daemon always has a fresh token
       _push?.onMqttConnected();
     };
-    _ws!.onResume = () async {
-      // App came back from background — reload messages and catch up.
-      _chatLog('onResume: reloading messages and sending catch_up');
+    _ws!.onResume = () {
+      // App came back from background. The in-memory state already has
+      // any messages received while suspended (addMessage was called).
+      // Just rebuild the UI and scroll to bottom to show them.
+      _chatLog('onResume: rebuilding UI and sending catch_up');
       _sendCommand('catch_up', {'lastSeq': _lastSeq});
-      // Force reload current session messages from provider (triggers rebuild)
-      final activeId = ref.read(activeSessionIdProvider);
-      if (activeId != null) {
-        // Re-save then re-load to ensure UI matches persisted state
-        await ref.read(messagesProvider.notifier).switchSession(activeId);
-      }
       if (mounted) {
         setState(() {});
-        _scrollToBottom();
+        // Scroll after the frame rebuilds
+        WidgetsBinding.instance.addPostFrameCallback((_) {
+          if (mounted) _scrollToBottom();
+        });
       }
     };
     _ws!.onError = (error) {
@@ -260,11 +259,12 @@
     // sent immediately if already connected.
     _push = PushService(mqttService: _ws!);
     _push!.onNotificationTap = (data) {
-      // Switch to the session immediately, then request catch_up.
-      // The MQTT connection auto-reconnects on resume, and onResume
-      // already sends catch_up. We just need to be on the right session.
+      // Only switch if tapping a notification for a DIFFERENT session.
+      // If already on this session, the message is already displayed —
+      // calling switchSession would reload from disk and lose it.
       final sessionId = data['sessionId'] as String?;
-      if (sessionId != null && mounted) {
+      final activeId = ref.read(activeSessionIdProvider);
+      if (sessionId != null && sessionId != activeId && mounted) {
         _switchSession(sessionId);
       }
     };
@@ -414,6 +414,7 @@
               );
             }
 
+            _chatLog('catch_up msg: session=${msgSessionId?.substring(0, 8) ?? "NULL"} active=${activeId?.substring(0, 8)} match=${msgSessionId == activeId || msgSessionId == null} content="${content.substring(0, content.length.clamp(0, 40))}"');
             if (msgSessionId == null || msgSessionId == activeId) {
               // Active session or no session: add directly to chat
               ref.read(messagesProvider.notifier).addMessage(message);
diff --git a/lib/services/message_store.dart b/lib/services/message_store.dart
index 2d682ec..f45e0d0 100644
--- a/lib/services/message_store.dart
+++ b/lib/services/message_store.dart
@@ -6,6 +6,7 @@
 import 'package:path_provider/path_provider.dart';
 
 import '../models/message.dart';
+import 'trace_service.dart';
 
 /// Per-session JSON file persistence with debounced saves.
 class MessageStore {
@@ -59,6 +60,8 @@
 
   /// Write directly to disk, bypassing debounce. For critical saves.
   static Future<void> writeDirect(String sessionId, List<Message> messages) async {
+    // Cancel ALL pending debounce to prevent race with frozen iOS timers
+    _debounceTimer?.cancel();
     _pendingSaves.remove(sessionId);
     await _writeSession(sessionId, messages);
   }
@@ -85,9 +88,11 @@
       final file = File('${dir.path}/${_fileForSession(sessionId)}');
       // Strip heavy fields for persistence
       final lightMessages = messages.map((m) => m.toJsonLight()).toList();
-      await file.writeAsString(jsonEncode(lightMessages));
+      final json = jsonEncode(lightMessages);
+      await file.writeAsString(json);
+      TraceService.instance.addTrace('MsgStore WRITE', '${sessionId.substring(0, 8)}: ${messages.length} msgs');
     } catch (e) {
-      // Silently fail - message persistence is best-effort
+      TraceService.instance.addTrace('MsgStore WRITE ERROR', '${sessionId.substring(0, 8)}: $e');
     }
   }
 
@@ -130,11 +135,14 @@
 
       final jsonStr = await file.readAsString();
       final List<dynamic> jsonList = jsonDecode(jsonStr) as List<dynamic>;
-      return jsonList
+      final msgs = jsonList
           .map((j) => _messageFromJson(j as Map<String, dynamic>))
           .where((m) => !m.isEmptyVoice && !m.isEmptyText)
           .toList();
+      TraceService.instance.addTrace('MsgStore LOAD', '${sessionId.substring(0, 8)}: ${msgs.length} msgs');
+      return msgs;
     } catch (e) {
+      TraceService.instance.addTrace('MsgStore LOAD ERROR', '${sessionId.substring(0, 8)}: $e');
       return [];
     }
   }

--
Gitblit v1.3.1