| lib/providers/providers.dart | patch | view | blame | history | |
| lib/screens/chat_screen.dart | patch | view | blame | history | |
| lib/services/message_store.dart | patch | view | blame | history |
lib/providers/providers.dart
.. .. @@ -9,6 +9,7 @@ 9 9 import '../models/server_config.dart'; 10 10 import '../models/session.dart'; 11 11 import '../services/message_store.dart'; 12 +import '../services/trace_service.dart';12 13 import '../services/mqtt_service.dart' show ConnectionStatus; 13 14 import '../services/navigate_notifier.dart'; 14 15 .. .. @@ -99,6 +100,12 @@ 99 100 100 101 /// Switch to a new session and load its messages. 101 102 Future<void> switchSession(String sessionId) async { 103 + // Log caller for debugging104 + final trace = StackTrace.current.toString().split('\n').take(4).join(' | ');105 + TraceService.instance.addTrace(106 + 'switchSession',107 + 'from=${_currentSessionId?.substring(0, 8) ?? "null"}(${state.length}) → ${sessionId.substring(0, 8)} | $trace',108 + );102 109 // Write current session DIRECTLY to disk (no debounce — prevents data loss) 103 110 if (_currentSessionId != null && state.isNotEmpty) { 104 111 await MessageStore.writeDirect(_currentSessionId!, state); .. .. @@ -113,7 +120,8 @@ 113 120 void addMessage(Message message) { 114 121 state = [...state, message]; 115 122 if (_currentSessionId != null) { 116 - MessageStore.save(_currentSessionId!, state);123 + // Write immediately (not debounced) to prevent race with switchSession's loadAll124 + MessageStore.writeDirect(_currentSessionId!, state);117 125 } 118 126 } 119 127 lib/screens/chat_screen.dart
.. .. @@ -223,19 +223,18 @@ 223 223 // Re-register APNs token after reconnect so daemon always has a fresh token 224 224 _push?.onMqttConnected(); 225 225 }; 226 - _ws!.onResume = () async {227 - // App came back from background — reload messages and catch up.228 - _chatLog('onResume: reloading messages and sending catch_up');226 + _ws!.onResume = () {227 + // App came back from background. The in-memory state already has228 + // any messages received while suspended (addMessage was called).229 + // Just rebuild the UI and scroll to bottom to show them.230 + _chatLog('onResume: rebuilding UI and sending catch_up');229 231 _sendCommand('catch_up', {'lastSeq': _lastSeq}); 230 - // Force reload current session messages from provider (triggers rebuild)231 - final activeId = ref.read(activeSessionIdProvider);232 - if (activeId != null) {233 - // Re-save then re-load to ensure UI matches persisted state234 - await ref.read(messagesProvider.notifier).switchSession(activeId);235 - }236 232 if (mounted) { 237 233 setState(() {}); 238 - _scrollToBottom();234 + // Scroll after the frame rebuilds235 + WidgetsBinding.instance.addPostFrameCallback((_) {236 + if (mounted) _scrollToBottom();237 + });239 238 } 240 239 }; 241 240 _ws!.onError = (error) { .. .. @@ -260,11 +259,12 @@ 260 259 // sent immediately if already connected. 261 260 _push = PushService(mqttService: _ws!); 262 261 _push!.onNotificationTap = (data) { 263 - // Switch to the session immediately, then request catch_up.264 - // The MQTT connection auto-reconnects on resume, and onResume265 - // already sends catch_up. We just need to be on the right session.262 + // Only switch if tapping a notification for a DIFFERENT session.263 + // If already on this session, the message is already displayed —264 + // calling switchSession would reload from disk and lose it.266 265 final sessionId = data['sessionId'] as String?; 267 - if (sessionId != null && mounted) {266 + final activeId = ref.read(activeSessionIdProvider);267 + if (sessionId != null && sessionId != activeId && mounted) {268 268 _switchSession(sessionId); 269 269 } 270 270 }; .. .. @@ -414,6 +414,7 @@ 414 414 ); 415 415 } 416 416 417 + _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))}"');417 418 if (msgSessionId == null || msgSessionId == activeId) { 418 419 // Active session or no session: add directly to chat 419 420 ref.read(messagesProvider.notifier).addMessage(message); lib/services/message_store.dart
.. .. @@ -6,6 +6,7 @@ 6 6 import 'package:path_provider/path_provider.dart'; 7 7 8 8 import '../models/message.dart'; 9 +import 'trace_service.dart';9 10 10 11 /// Per-session JSON file persistence with debounced saves. 11 12 class MessageStore { .. .. @@ -59,6 +60,8 @@ 59 60 60 61 /// Write directly to disk, bypassing debounce. For critical saves. 61 62 static Future<void> writeDirect(String sessionId, List<Message> messages) async { 63 + // Cancel ALL pending debounce to prevent race with frozen iOS timers64 + _debounceTimer?.cancel();62 65 _pendingSaves.remove(sessionId); 63 66 await _writeSession(sessionId, messages); 64 67 } .. .. @@ -85,9 +88,11 @@ 85 88 final file = File('${dir.path}/${_fileForSession(sessionId)}'); 86 89 // Strip heavy fields for persistence 87 90 final lightMessages = messages.map((m) => m.toJsonLight()).toList(); 88 - await file.writeAsString(jsonEncode(lightMessages));91 + final json = jsonEncode(lightMessages);92 + await file.writeAsString(json);93 + TraceService.instance.addTrace('MsgStore WRITE', '${sessionId.substring(0, 8)}: ${messages.length} msgs');89 94 } catch (e) { 90 - // Silently fail - message persistence is best-effort95 + TraceService.instance.addTrace('MsgStore WRITE ERROR', '${sessionId.substring(0, 8)}: $e');91 96 } 92 97 } 93 98 .. .. @@ -130,11 +135,14 @@ 130 135 131 136 final jsonStr = await file.readAsString(); 132 137 final List<dynamic> jsonList = jsonDecode(jsonStr) as List<dynamic>; 133 - return jsonList138 + final msgs = jsonList134 139 .map((j) => _messageFromJson(j as Map<String, dynamic>)) 135 140 .where((m) => !m.isEmptyVoice && !m.isEmptyText) 136 141 .toList(); 142 + TraceService.instance.addTrace('MsgStore LOAD', '${sessionId.substring(0, 8)}: ${msgs.length} msgs');143 + return msgs;137 144 } catch (e) { 145 + TraceService.instance.addTrace('MsgStore LOAD ERROR', '${sessionId.substring(0, 8)}: $e');138 146 return []; 139 147 } 140 148 }