153
154
bool get _isInMemoryChat => state.isTemporary;
155
156
+ /// Conversation the in-flight reply belongs to; null when idle. May differ
157
+ /// from the open chat while a reply finishes in the background (#94).
158
+ String? _streamConversationId;
159
+
160
+ /// Persisted conversation of the most recent stream. Its messages still
161
+ /// save while a temporary chat is open (the reply may finish in the
162
+ /// background). Not cleared on completion so late final saves still land.
163
+ String? _persistedStreamConversationId;
164
+
165
+ /// Service that owns the in-flight stream. Cancelling must target it even
166
+ /// if the active server changed since the stream started.
167
+ ChatService? _streamChatService;
168
+
169
+ /// True while a reply for another conversation is still generating.
170
+ bool get isGeneratingElsewhere =>
171
+ _streamConversationId != null &&
172
+ _streamConversationId != _activeConversationId;
173
+
174
+ void _setGlobalStreaming(bool streaming) {
175
+ if (streaming) {
176
+ _streamConversationId = _activeConversationId;
177
+ } else {
178
+ _streamConversationId = null;
179
+ _streamChatService = null;
180
+ }
181
+ ref.read(isStreamingProvider.notifier).setStreaming(streaming);
182
+ ref
183
+ .read(streamingConversationIdProvider.notifier)
184
+ .set(_streamConversationId);
185
+ }
186
+
187
+ /// Whether the open chat's reply may keep streaming after the user leaves
188
+ /// it. Temporary chats aren't persisted, tool approvals need the chat on
189
+ /// screen, and voice mode drives its own turn-taking.
190
+ bool _canContinueInBackground() {
191
+ if (!state.isStreaming || _isInMemoryChat) return false;
192
+ if (_streamConversationId == null ||
193
+ _streamConversationId != _activeConversationId) {
194
+ return false;
195
+ }
196
+ if (_pendingToolApproval != null) return false;
197
+ if (ref.read(chatMcpConfigProvider).enabled) return false;
198
+ return ref.read(voiceModeProvider).phase == VoiceModePhase.idle;
199
+ }
200
+
201
+ /// Only one reply generates at a time. Returns true (and explains why)
202
+ /// when another chat's reply is still running in the background.
203
+ bool _blockIfGeneratingElsewhere() {
204
+ if (!isGeneratingElsewhere) return false;
205
+ state = state.copyWith(errorMessage: generatingElsewhereMessage);
206
+ return true;
207
+ }
208
+
209
+ /// Cancels the reply generating for [conversationId], if any. Used before
210
+ /// deleting a conversation that may be streaming in the background.
211
+ Future<void> cancelGenerationFor(String conversationId) async {
212
+ if (_streamConversationId != conversationId) return;
213
+ await _detachStreamSubscription();
214
+ _uiUpdateTimer?.cancel();
215
+ _uiUpdateTimer = null;
216
+ if (!ref.mounted) return;
217
+ (_streamChatService ?? ref.read(chatServiceProvider))?.cancelStream();
218
+ ref.read(chatBackgroundServiceProvider).stop();
219
+ _latestStreamingMessage = null;
220
+ _setGlobalStreaming(false);
221
+ if (_activeConversationId == conversationId) {
222
+ state = state.copyWith(isStreaming: false, clearStreaming: true);
223
+ }
224
+ }
225
+
226
void _resetStreamMetrics() {
227
_streamStats = null;
228
_streamStartTime = DateTime.now();
319
final timeline = isCurrentContext
320
? state.messages
321
: MessageVariants.resolveActiveTimeline(
252
- await ref
253
- .read(databaseProvider)
254
- .store
255
- .runInTransactionAsync(
256
- TxMode.read,
257
- _loadMessagesInBackground,
258
- conversationId,
259
- ),
322
+ await loadConversationMessages(conversationId),
323
);
324
325
if (!ref.mounted) return;
342
if (!ref.mounted) return;
343
_recomputeConversationTotal(conversationId, messages: timeline);
344
282
- if (isCurrentContext) {
283
- final lastUserMessage = timeline
284
- .where((m) => m.role == MessageRole.user)
285
- .firstOrNull;
286
- if (lastUserMessage != null) {
287
- _maybeAutoGenerateTitleAfterFirstReply();
288
- }
345
+ final lastUserMessage = timeline
346
+ .where((m) => m.role == MessageRole.user)
347
+ .firstOrNull;
348
+ if (lastUserMessage != null) {
349
+ // Also covers a first reply that finished in the background (#94).
350
+ _maybeAutoGenerateTitleAfterFirstReply(
351
+ conversationId: conversationId,
352
+ timeline: timeline,
353
+ );
354
}
355
}
356
435
}
436
437
Future<void> loadConversation(Conversation conversation) async {
373
- await cancelStream();
438
+ if (!ref.mounted) return;
439
+ // Leave an eligible reply streaming in the background (#94). Only the
440
+ // open chat's own stream may be cancelled here; a reply already running
441
+ // in the background for another chat must survive further switches.
442
+ if (state.isStreaming && !_canContinueInBackground()) {
443
+ await cancelStream();
444
+ } else if (!state.isStreaming) {
445
+ _clearPendingApproval();
446
+ }
447
if (!ref.mounted) return;
448
_currentConversationId = conversation.id;
449
ref.read(smartReplyServiceProvider).reset();
454
);
455
456
try {
384
- final db = ref.read(databaseProvider);
385
-
386
- final messages = await db.store.runInTransactionAsync(
387
- TxMode.read,
388
- _loadMessagesInBackground,
389
- conversation.id,
390
- );
457
+ final messages = await loadConversationMessages(conversation.id);
458
459
if (!ref.mounted) return;
460
461
+ // Reattach to a reply that kept generating while this chat was closed.
462
+ final liveMessage = _streamConversationId == conversation.id
463
+ ? _latestStreamingMessage
464
+ : null;
465
state = ChatState(
466
allMessages: messages,
467
messages: MessageVariants.resolveActiveTimeline(messages),
468
isLoading: false,
469
isTemporary: conversation.isTemporary,
470
+ isStreaming: liveMessage != null,
471
+ streamingMessage: liveMessage,
472
);
473
ref
474
.read(conv.activeConversationIdProvider.notifier)
582
}
583
584
Future<void> startNewConversation() async {
512
- await _abortStreamImmediately();
585
+ if (!ref.mounted) return;
586
+ if (state.isStreaming && !_canContinueInBackground()) {
587
+ await _abortStreamImmediately();
588
+ } else {
589
+ _clearPendingApproval();
590
+ }
591
if (!ref.mounted) return;
592
593
_currentConversationId = null;
628
629
if (!ref.mounted) return;
630
553
- ref.read(chatServiceProvider)?.cancelStream();
631
+ (_streamChatService ?? ref.read(chatServiceProvider))?.cancelStream();
632
ref.read(chatBackgroundServiceProvider).stop();
555
- ref.read(isStreamingProvider.notifier).setStreaming(false);
633
+ _setGlobalStreaming(false);
634
635
_chunkCount = 0;
636
_lastCheckpointTime = null;
843
}
844
845
Future<void> sendMessage(String content, {List<File>? attachments}) async {
846
+ if (_blockIfGeneratingElsewhere()) return;
847
final target = ref.read(activeChatTargetProvider);
848
final server = target.server;
849
final selectedModel = target.selectedModel;
961
streamingMessage: assistantMessage,
962
clearError: true,
963
);
885
- ref.read(isStreamingProvider.notifier).setStreaming(true);
964
+ _setGlobalStreaming(true);
965
966
await _saveMessage(userMessage);
967
await _saveMessage(assistantMessage);
1039
});
1040
1041
final streamGeneration = _streamGeneration;
1042
+ final streamInMemory = _isInMemoryChat;
1043
+ _persistedStreamConversationId = streamInMemory
1044
+ ? null
1045
+ : assistantMessage.conversationId;
1046
+ _streamChatService = chatService;
1047
_streamSubscription = chatService
1048
.sendMessage(
1049
server: server,
1077
1078
_chunkCount++;
1079
996
- if (!_isInMemoryChat &&
1080
+ if (!streamInMemory &&
1081
_shouldCheckpointSave(streamingAssistantMessage)) {
1082
_saveService?.enqueue(streamingAssistantMessage);
1083
_updateSavedMetrics(streamingAssistantMessage);
1100
1101
_chunkCount++;
1102
1019
- if (!_isInMemoryChat &&
1103
+ if (!streamInMemory &&
1104
_shouldCheckpointSave(streamingAssistantMessage)) {
1105
_saveService?.enqueue(streamingAssistantMessage);
1106
_updateSavedMetrics(streamingAssistantMessage);
1146
errorMessage: streamingAssistantMessage.errorMessage,
1147
);
1148
}
1065
- ref.read(isStreamingProvider.notifier).setStreaming(false);
1149
+ _setGlobalStreaming(false);
1150
_latestStreamingMessage = null;
1151
await _saveService?.flush();
1152
await _saveMessage(streamingAssistantMessage);
1173
// The error branch already finalised the message and
1174
// stopped streaming — do not overwrite the error state
1175
// with success/default text.
1092
- if (!_isInMemoryChat) {
1176
+ if (!streamInMemory) {
1177
await _saveService?.flush();
1178
}
1179
if (ref.mounted) {
1182
return;
1183
}
1184
1101
- if (!_isInMemoryChat) {
1185
+ if (!streamInMemory) {
1186
await _saveService?.flush();
1187
}
1188
if (ref.mounted) {
1212
);
1213
}
1214
if (ref.mounted) {
1131
- ref.read(isStreamingProvider.notifier).setStreaming(false);
1215
+ _setGlobalStreaming(false);
1216
}
1217
_latestStreamingMessage = null;
1218
} else {
1380
_maybeAutoGenerateTitleAfterFirstReply();
1381
}
1382
if (ref.mounted) {
1299
- ref.read(isStreamingProvider.notifier).setStreaming(false);
1383
+ _setGlobalStreaming(false);
1384
}
1385
_latestStreamingMessage = null;
1386
1412
final voiceActive =
1413
ref.read(voiceModeProvider).phase !=
1414
VoiceModePhase.idle;
1415
+ // Don't read a background reply aloud over another
1416
+ // chat (#94).
1417
if (autoSpeak &&
1418
!voiceActive &&
1419
+ _activeConversationId == finalMessage.conversationId &&
1420
finalMessage.content.trim().isNotEmpty) {
1421
ref
1422
.read(ttsProvider.notifier)
1460
}
1461
}
1462
if (ref.mounted) {
1376
- ref.read(isStreamingProvider.notifier).setStreaming(false);
1463
+ _setGlobalStreaming(false);
1464
}
1465
_latestStreamingMessage = null;
1466
},
1483
errorMessage: e.toString(),
1484
clearStreaming: true,
1485
);
1399
- ref.read(isStreamingProvider.notifier).setStreaming(false);
1486
+ _setGlobalStreaming(false);
1487
_latestStreamingMessage = null;
1488
ref.read(chatBackgroundServiceProvider).stop();
1489
}
1747
_titleGenerationFutures.containsKey(conversationId);
1748
}
1749
1663
- Future<void>? _maybeAutoGenerateTitleAfterFirstReply() {
1664
- if (_currentConversationId == null || _isInMemoryChat) return null;
1750
+ /// Titles a chat after its first reply. Defaults to the open chat; pass
1751
+ /// [conversationId] and its [timeline] for a reply that finished in the
1752
+ /// background.
1753
+ Future<void>? _maybeAutoGenerateTitleAfterFirstReply({
1754
+ String? conversationId,
1755
+ List<Message>? timeline,
1756
+ }) {
1757
+ final convId = conversationId ?? _currentConversationId;
1758
+ if (convId == null) return null;
1759
+ final isOpenChat = convId == _currentConversationId;
1760
+ if (isOpenChat && _isInMemoryChat) return null;
1761
1762
final settings = ref.read(settingsProvider);
1763
if (!settings.autoGenerateTitle) return null;
1764
1669
- final activeConv = ref.read(conv.activeConversationProvider);
1670
- if (activeConv == null || activeConv.title != 'New Chat') return null;
1671
-
1672
- final assistantCount = state.messages
1765
+ final conversation = isOpenChat
1766
+ ? ref.read(conv.activeConversationProvider)
1767
+ : ref
1768
+ .read(conv.conversationsProvider)
1769
+ .value
1770
+ ?.where((c) => c.id == convId)
1771
+ .firstOrNull;
1772
+ if (conversation == null ||
1773
+ conversation.isTemporary ||
1774
+ conversation.title != 'New Chat') {
1775
+ return null;
1776
+ }
1777
+
1778
+ final messages = timeline ?? state.messages;
1779
+ final assistantCount = messages
1780
.where((m) => m.role == MessageRole.assistant)
1781
.length;
1782
if (assistantCount != 1) return null;
1783
1677
- final convId = _currentConversationId!;
1784
return _generateAndApplyTitle(convId);
1785
}
1786
1956
_clearPendingApproval();
1957
if (!ref.mounted) return;
1958
1853
- ref.read(chatServiceProvider)?.cancelStream();
1959
+ (_streamChatService ?? ref.read(chatServiceProvider))?.cancelStream();
1960
ref.read(chatBackgroundServiceProvider).stop();
1961
1962
_chunkCount = 0;
1983
1984
if (!ref.mounted) return;
1985
state = state.copyWith(isStreaming: false, clearStreaming: true);
1880
- ref.read(isStreamingProvider.notifier).setStreaming(false);
1986
+ _setGlobalStreaming(false);
1987
_latestStreamingMessage = null;
1988
}
1989
1990
Future<void> retryLastMessage() async {
1991
+ if (_blockIfGeneratingElsewhere()) return;
1992
final messages = state.messages;
1993
if (messages.isEmpty) return;
1994
2037
}
2038
2039
Future<void> continueFromMessage(String messageId) async {
2040
+ if (_blockIfGeneratingElsewhere()) return;
2041
final messages = state.messages;
2042
if (messages.isEmpty || messages.last.id != messageId) return;
2043
final assistant = messages.last;
2062
streamingMessage: streamingAssistant,
2063
clearError: true,
2064
);
1957
- ref.read(isStreamingProvider.notifier).setStreaming(true);
2065
+ _setGlobalStreaming(true);
2066
ref.read(chatBackgroundServiceProvider).start();
2067
_resetCheckpointMetrics();
2068
_updateSavedMetrics(streamingAssistant);
2121
}
2122
2123
Future<void> retryMessage(String messageId) async {
2124
+ if (_blockIfGeneratingElsewhere()) return;
2125
if (state.isStreaming || _isRegenerating) return;
2126
_isRegenerating = true;
2127
try {
2165
required int threadOrder,
2166
required int variantIndex,
2167
}) async {
2168
+ if (_blockIfGeneratingElsewhere()) return;
2169
final target = ref.read(activeChatTargetProvider);
2170
final selectedModel = target.selectedModel;
2171
final server = target.server;
2202
streamingMessage: assistantMessage,
2203
clearError: true,
2204
);
2095
- ref.read(isStreamingProvider.notifier).setStreaming(true);
2205
+ _setGlobalStreaming(true);
2206
await _saveMessage(assistantMessage);
2207
2208
ref.read(chatBackgroundServiceProvider).start();
2339
streamingMessage: continuationMessage,
2340
clearStreaming: false,
2341
);
2232
- ref.read(isStreamingProvider.notifier).setStreaming(true);
2342
+ _setGlobalStreaming(true);
2343
ref.read(chatBackgroundServiceProvider).start();
2344
await _saveMessage(continuationMessage);
2345
if (!ref.mounted) return;
2375
await _saveMessage(errorMessage);
2376
_replaceMessageInAll(errorMessage, clearStreaming: true);
2377
state = state.copyWith(isStreaming: false, clearStreaming: true);
2268
- ref.read(isStreamingProvider.notifier).setStreaming(false);
2378
+ _setGlobalStreaming(false);
2379
ref.read(chatBackgroundServiceProvider).stop();
2380
return;
2381
}
2423
});
2424
2425
final streamGeneration = _streamGeneration;
2426
+ final streamInMemory = _isInMemoryChat;
2427
+ _persistedStreamConversationId = streamInMemory
2428
+ ? null
2429
+ : assistantMessage.conversationId;
2430
+ _streamChatService = chatService;
2431
_streamSubscription = chatService
2432
.sendMessage(
2433
server: server,
2469
);
2470
_latestStreamingMessage = streamingAssistantMessage;
2471
_chunkCount++;
2357
- if (!_isInMemoryChat &&
2472
+ if (!streamInMemory &&
2473
_shouldCheckpointSave(streamingAssistantMessage)) {
2474
_saveService?.enqueue(streamingAssistantMessage);
2475
_updateSavedMetrics(streamingAssistantMessage);
2489
);
2490
_latestStreamingMessage = streamingAssistantMessage;
2491
_chunkCount++;
2377
- if (!_isInMemoryChat &&
2492
+ if (!streamInMemory &&
2493
_shouldCheckpointSave(streamingAssistantMessage)) {
2494
_saveService?.enqueue(streamingAssistantMessage);
2495
_updateSavedMetrics(streamingAssistantMessage);
2528
clearStreaming: true,
2529
);
2530
}
2416
- ref.read(isStreamingProvider.notifier).setStreaming(false);
2531
+ _setGlobalStreaming(false);
2532
ref.read(chatBackgroundServiceProvider).stop();
2533
}
2534
break;
2571
_maybeAutoGenerateTitleAfterFirstReply();
2572
}
2573
if (ref.mounted) {
2459
- ref.read(isStreamingProvider.notifier).setStreaming(false);
2574
+ _setGlobalStreaming(false);
2575
ref.read(chatBackgroundServiceProvider).stop();
2576
}
2577
await _syncConversationStatsAfterGeneration(
2592
final autoSpeak = ref.read(settingsProvider).autoSpeakEnabled;
2593
final voiceActive =
2594
ref.read(voiceModeProvider).phase != VoiceModePhase.idle;
2595
+ // Don't read a background reply aloud over another chat.
2596
if (autoSpeak &&
2597
!voiceActive &&
2598
+ _activeConversationId == finalMessage.conversationId &&
2599
finalMessage.content.trim().isNotEmpty) {
2600
ref
2601
.read(ttsProvider.notifier)
2629
);
2630
}
2631
if (ref.mounted) {
2515
- ref.read(isStreamingProvider.notifier).setStreaming(false);
2632
+ _setGlobalStreaming(false);
2633
ref.read(chatBackgroundServiceProvider).stop();
2634
}
2635
},
2641
errorMessage: e.toString(),
2642
clearStreaming: true,
2643
);
2527
- ref.read(isStreamingProvider.notifier).setStreaming(false);
2644
+ _setGlobalStreaming(false);
2645
}
2646
}
2647
2786
}
2787
2788
Future<void> generateResponseForLastUser() async {
2789
+ if (_blockIfGeneratingElsewhere()) return;
2790
if (state.isStreaming || _isRegenerating) return;
2791
_isRegenerating = true;
2792
try {
2806
}
2807
2808
Future<void> generateAiUserMessage() async {
2809
+ if (_blockIfGeneratingElsewhere()) return;
2810
if (state.isStreaming) return;
2811
if (state.messages.isEmpty) return;
2812
2887
}
2888
2889
Future<void> editMessage(String messageId, String newContent) async {
2890
+ if (_blockIfGeneratingElsewhere()) return;
2891
final messageIndex = state.messages.indexWhere((m) => m.id == messageId);
2892
if (messageIndex == -1) return;
2893
2932
// Check `ref.mounted` before touching `state` (via `_isInMemoryChat`).
2933
// On a disposed notifier, accessing `state` throws "Cannot use the Ref of
2934
// NotifierProvider ... after it has been disposed" — see issue #73.
2815
- if (!ref.mounted || _isInMemoryChat) return;
2935
+ if (!ref.mounted) return;
2936
+ // A background reply belongs to a persisted chat even while a temporary
2937
+ // chat is open, so only skip messages that aren't from that reply.
2938
+ if (_isInMemoryChat &&
2939
+ message.conversationId != _persistedStreamConversationId) {
2940
+ return;
2941
+ }
2942
+ await persistMessage(message);
2943
+ }
2944
+
2945
+ /// Reads a conversation's messages from ObjectBox. Overridden in tests.
2946
+ @protected
2947
+ @visibleForTesting
2948
+ Future<List<Message>> loadConversationMessages(String conversationId) {
2949
+ final db = ref.read(databaseProvider);
2950
+ return db.store.runInTransactionAsync(
2951
+ TxMode.read,
2952
+ _loadMessagesInBackground,
2953
+ conversationId,
2954
+ );
2955
+ }
2956
+
2957
+ /// Writes a message to ObjectBox. Overridden in tests.
2958
+ @protected
2959
+ @visibleForTesting
2960
+ Future<void> persistMessage(Message message) async {
2961
final db = ref.read(databaseProvider);
2962
await db.store.runInTransactionAsync(
2963
TxMode.write,