Matthias Nott
2026-03-24 cb470d33d2665fcc6f8448d2736777656cf0cbe7
lib/screens/chat_screen.dart
....@@ -56,6 +56,9 @@
5656 bool _isCatchingUp = false;
5757 bool _screenshotForChat = false;
5858 final Set<int> _seenSeqs = {};
59
+ bool _sessionReady = false;
60
+ final List<Map<String, dynamic>> _pendingMessages = [];
61
+ final Map<String, List<Message>> _catchUpPending = {};
5962
6063 @override
6164 void initState() {
....@@ -66,9 +69,14 @@
6669 }
6770
6871 Future<void> _initAll() async {
69
- // Load lastSeq BEFORE connecting so catch_up sends the right value
72
+ // Load persisted state BEFORE connecting
7073 final prefs = await SharedPreferences.getInstance();
7174 _lastSeq = prefs.getInt('lastSeq') ?? 0;
75
+ // Restore last active session so catch_up routes to the right session
76
+ final savedSessionId = prefs.getString('activeSessionId');
77
+ if (savedSessionId != null && mounted) {
78
+ ref.read(activeSessionIdProvider.notifier).state = savedSessionId;
79
+ }
7280 if (!mounted) return;
7381
7482 // Listen for playback state changes to reset play button UI
....@@ -146,10 +154,11 @@
146154 };
147155 _ws!.onMessage = _handleMessage;
148156 _ws!.onOpen = () {
157
+ _sessionReady = false; // Gate messages until sessions arrive
158
+ _pendingMessages.clear();
149159 final activeId = ref.read(activeSessionIdProvider);
150160 _sendCommand('sync', activeId != null ? {'activeSessionId': activeId} : null);
151
- // catch_up is still available during the transition period
152
- _sendCommand('catch_up', {'lastSeq': _lastSeq});
161
+ // catch_up is sent after sessions arrive (in _handleSessions)
153162 };
154163 _ws!.onError = (error) {
155164 debugPrint('MQTT error: $error');
....@@ -168,6 +177,14 @@
168177 }
169178
170179 void _handleMessage(Map<String, dynamic> msg) {
180
+ final type = msg['type'] as String?;
181
+ // Sessions and catch_up always process immediately
182
+ // Content messages (text, voice, image) wait until session is ready
183
+ if (!_sessionReady && type != 'sessions' && type != 'catch_up' && type != 'status' && type != 'typing') {
184
+ _pendingMessages.add(msg);
185
+ return;
186
+ }
187
+
171188 // Track sequence numbers for catch_up protocol
172189 final seq = msg['seq'] as int?;
173190 if (seq != null) {
....@@ -184,8 +201,6 @@
184201 _saveLastSeq();
185202 }
186203 }
187
-
188
- final type = msg['type'] as String?;
189204
190205 switch (type) {
191206 case 'sessions':
....@@ -231,7 +246,8 @@
231246 if (sessionId != null) _incrementUnread(sessionId);
232247 case 'catch_up':
233248 final serverSeq = msg['serverSeq'] as int?;
234
- if (serverSeq != null && serverSeq > _lastSeq) {
249
+ if (serverSeq != null) {
250
+ // Always sync to server's seq — if server restarted, its seq may be lower
235251 _lastSeq = serverSeq;
236252 _saveLastSeq();
237253 }
....@@ -241,19 +257,91 @@
241257 final catchUpMsgs = msg['messages'] as List<dynamic>?;
242258 if (catchUpMsgs != null && catchUpMsgs.isNotEmpty) {
243259 _isCatchingUp = true;
260
+ final activeId = ref.read(activeSessionIdProvider);
244261 final existing = ref.read(messagesProvider);
245262 final existingContents = existing
246263 .where((m) => m.role == MessageRole.assistant)
247264 .map((m) => m.content)
248265 .toSet();
249266 for (final m in catchUpMsgs) {
250
- final content = (m as Map<String, dynamic>)['content'] as String? ?? '';
251
- // Skip if we already have this message locally
252
- if (content.isNotEmpty && existingContents.contains(content)) continue;
253
- _handleMessage(m);
254
- if (content.isNotEmpty) existingContents.add(content);
267
+ final map = m as Map<String, dynamic>;
268
+ final msgType = map['type'] as String? ?? 'text';
269
+ final content = map['content'] as String? ?? map['transcript'] as String? ?? map['caption'] as String? ?? '';
270
+ final msgSessionId = map['sessionId'] as String?;
271
+ final imageData = map['imageBase64'] as String?;
272
+
273
+ // Skip empty text messages (images with no caption are OK)
274
+ if (content.isEmpty && imageData == null) continue;
275
+ // Dedup by content (skip images from dedup — they have unique msgIds)
276
+ if (imageData == null && content.isNotEmpty && existingContents.contains(content)) continue;
277
+
278
+ final Message message;
279
+ if (msgType == 'image' && imageData != null) {
280
+ message = Message.image(
281
+ role: MessageRole.assistant,
282
+ imageBase64: imageData,
283
+ content: content,
284
+ status: MessageStatus.sent,
285
+ );
286
+ } else {
287
+ message = Message.text(
288
+ role: MessageRole.assistant,
289
+ content: content,
290
+ status: MessageStatus.sent,
291
+ );
292
+ }
293
+
294
+ if (msgSessionId == null || msgSessionId == activeId) {
295
+ // Active session or no session: add directly to chat
296
+ ref.read(messagesProvider.notifier).addMessage(message);
297
+ } else {
298
+ // Different session: store + unread badge + toast
299
+ // Collect for batch storage below to avoid race condition
300
+ _catchUpPending.putIfAbsent(msgSessionId, () => []).add(message);
301
+ _incrementUnread(msgSessionId);
302
+ }
303
+ existingContents.add(content);
255304 }
256305 _isCatchingUp = false;
306
+ _scrollToBottom();
307
+ // Batch-store cross-session messages (sequential to avoid race condition)
308
+ if (_catchUpPending.isNotEmpty) {
309
+ final pending = Map<String, List<Message>>.from(_catchUpPending);
310
+ _catchUpPending.clear();
311
+ // Show one toast per session with message count
312
+ if (mounted) {
313
+ final sessions = ref.read(sessionsProvider);
314
+ for (final entry in pending.entries) {
315
+ final session = sessions.firstWhere(
316
+ (s) => s.id == entry.key,
317
+ orElse: () => Session(id: entry.key, index: 0, name: 'Unknown', type: 'claude'),
318
+ );
319
+ final count = entry.value.length;
320
+ final preview = count == 1
321
+ ? entry.value.first.content
322
+ : '$count messages';
323
+ ToastManager.show(
324
+ context,
325
+ sessionName: session.name,
326
+ preview: preview.length > 100 ? '${preview.substring(0, 100)}...' : preview,
327
+ onTap: () => _switchSession(entry.key),
328
+ );
329
+ }
330
+ }
331
+ () async {
332
+ for (final entry in pending.entries) {
333
+ final existing = await MessageStore.loadAll(entry.key);
334
+ MessageStore.save(entry.key, [...existing, ...entry.value]);
335
+ await MessageStore.flush();
336
+ }
337
+ }();
338
+ }
339
+ // Clear unread for active session
340
+ if (activeId != null) {
341
+ final counts = Map<String, int>.from(ref.read(unreadCountsProvider));
342
+ counts.remove(activeId);
343
+ ref.read(unreadCountsProvider.notifier).state = counts;
344
+ }
257345 }
258346 case 'pong':
259347 break; // heartbeat response, ignore
....@@ -284,6 +372,22 @@
284372 );
285373 ref.read(activeSessionIdProvider.notifier).state = active.id;
286374 ref.read(messagesProvider.notifier).switchSession(active.id);
375
+ SharedPreferences.getInstance().then((p) => p.setString('activeSessionId', active.id));
376
+ }
377
+
378
+ // Session is ready — process any pending messages that arrived before sessions list
379
+ if (!_sessionReady) {
380
+ _sessionReady = true;
381
+ // Request catch_up now that session is set
382
+ _sendCommand('catch_up', {'lastSeq': _lastSeq});
383
+ // Drain messages that arrived before sessions list
384
+ if (_pendingMessages.isNotEmpty) {
385
+ final pending = List<Map<String, dynamic>>.from(_pendingMessages);
386
+ _pendingMessages.clear();
387
+ for (final m in pending) {
388
+ _handleMessage(m);
389
+ }
390
+ }
287391 }
288392 }
289393
....@@ -507,6 +611,7 @@
507611
508612 ref.read(activeSessionIdProvider.notifier).state = sessionId;
509613 await ref.read(messagesProvider.notifier).switchSession(sessionId);
614
+ SharedPreferences.getInstance().then((p) => p.setString('activeSessionId', sessionId));
510615
511616 final counts = Map<String, int>.from(ref.read(unreadCountsProvider));
512617 counts.remove(sessionId);