@tduncan / Cortana / commits / de27783

feat(chat): keep a reply generating when switching chats #94

Switching conversations used to cancel the in-flight reply. Now a reply keeps streaming into its own conversation while you use another chat. - Leaving a chat no longer cancels its reply unless it can't continue safely (temporary chat, pending tool approval, MCP tools, voice mode). - Returning to the chat reattaches to the live reply. - One reply runs at a time: other chats show a notice (tap to open the generating chat) and can't send until it finishes; drafts are kept. - The sidebar shows a spinner on the generating chat, deleting it stops the reply first, and a first reply finished in the background still gets an auto title. - Handlers no longer read the open chat's temporary flag or chat service, so background saves and cancels target the right chat. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

abdulmominsakib committed Sep 29, 2026 at 00:47 UTC de277838df4a8fd029def0f7236207b38efbb1d9
39 files changed +911 -78
lib/features/chat/providers/chat_notifier.dart
+201 -56
@@ -153,6 +153,76 @@ class ChatNotifier extends Notifier<ChatState> {
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();
@@ -249,14 +319,7 @@ class ChatNotifier extends Notifier<ChatState> {
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;
@@ -279,13 +342,15 @@ class ChatNotifier extends Notifier<ChatState> {
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
@@ -370,7 +435,15 @@ class ChatNotifier extends Notifier<ChatState> {
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();
@@ -381,21 +454,21 @@ class ChatNotifier extends Notifier<ChatState> {
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)
@@ -509,7 +582,12 @@ class ChatNotifier extends Notifier<ChatState> {
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;
@@ -550,9 +628,9 @@ class ChatNotifier extends Notifier<ChatState> {
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;
@@ -765,6 +843,7 @@ class ChatNotifier extends Notifier<ChatState> {
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;
@@ -882,7 +961,7 @@ class ChatNotifier extends Notifier<ChatState> {
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);
@@ -960,6 +1039,11 @@ class ChatNotifier extends Notifier<ChatState> {
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,
@@ -993,7 +1077,7 @@ class ChatNotifier extends Notifier<ChatState> {
1077
1078 _chunkCount++;
1079
996 - if (!_isInMemoryChat &&
1080 + if (!streamInMemory &&
1081 _shouldCheckpointSave(streamingAssistantMessage)) {
1082 _saveService?.enqueue(streamingAssistantMessage);
1083 _updateSavedMetrics(streamingAssistantMessage);
@@ -1016,7 +1100,7 @@ class ChatNotifier extends Notifier<ChatState> {
1100
1101 _chunkCount++;
1102
1019 - if (!_isInMemoryChat &&
1103 + if (!streamInMemory &&
1104 _shouldCheckpointSave(streamingAssistantMessage)) {
1105 _saveService?.enqueue(streamingAssistantMessage);
1106 _updateSavedMetrics(streamingAssistantMessage);
@@ -1062,7 +1146,7 @@ class ChatNotifier extends Notifier<ChatState> {
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);
@@ -1089,7 +1173,7 @@ class ChatNotifier extends Notifier<ChatState> {
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) {
@@ -1098,7 +1182,7 @@ class ChatNotifier extends Notifier<ChatState> {
1182 return;
1183 }
1184
1101 - if (!_isInMemoryChat) {
1185 + if (!streamInMemory) {
1186 await _saveService?.flush();
1187 }
1188 if (ref.mounted) {
@@ -1128,7 +1212,7 @@ class ChatNotifier extends Notifier<ChatState> {
1212 );
1213 }
1214 if (ref.mounted) {
1131 - ref.read(isStreamingProvider.notifier).setStreaming(false);
1215 + _setGlobalStreaming(false);
1216 }
1217 _latestStreamingMessage = null;
1218 } else {
@@ -1296,7 +1380,7 @@ class ChatNotifier extends Notifier<ChatState> {
1380 _maybeAutoGenerateTitleAfterFirstReply();
1381 }
1382 if (ref.mounted) {
1299 - ref.read(isStreamingProvider.notifier).setStreaming(false);
1383 + _setGlobalStreaming(false);
1384 }
1385 _latestStreamingMessage = null;
1386
@@ -1328,8 +1412,11 @@ class ChatNotifier extends Notifier<ChatState> {
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)
@@ -1373,7 +1460,7 @@ class ChatNotifier extends Notifier<ChatState> {
1460 }
1461 }
1462 if (ref.mounted) {
1376 - ref.read(isStreamingProvider.notifier).setStreaming(false);
1463 + _setGlobalStreaming(false);
1464 }
1465 _latestStreamingMessage = null;
1466 },
@@ -1396,7 +1483,7 @@ class ChatNotifier extends Notifier<ChatState> {
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 }
@@ -1660,21 +1747,40 @@ class ChatNotifier extends Notifier<ChatState> {
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
@@ -1850,7 +1956,7 @@ class ChatNotifier extends Notifier<ChatState> {
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;
@@ -1877,11 +1983,12 @@ class ChatNotifier extends Notifier<ChatState> {
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
@@ -1930,6 +2037,7 @@ class ChatNotifier extends Notifier<ChatState> {
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;
@@ -1954,7 +2062,7 @@ class ChatNotifier extends Notifier<ChatState> {
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);
@@ -2013,6 +2121,7 @@ class ChatNotifier extends Notifier<ChatState> {
2121 }
2122
2123 Future<void> retryMessage(String messageId) async {
2124 + if (_blockIfGeneratingElsewhere()) return;
2125 if (state.isStreaming || _isRegenerating) return;
2126 _isRegenerating = true;
2127 try {
@@ -2056,6 +2165,7 @@ class ChatNotifier extends Notifier<ChatState> {
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;
@@ -2092,7 +2202,7 @@ class ChatNotifier extends Notifier<ChatState> {
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();
@@ -2229,7 +2339,7 @@ class ChatNotifier extends Notifier<ChatState> {
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;
@@ -2265,7 +2375,7 @@ class ChatNotifier extends Notifier<ChatState> {
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 }
@@ -2313,6 +2423,11 @@ class ChatNotifier extends Notifier<ChatState> {
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,
@@ -2354,7 +2469,7 @@ class ChatNotifier extends Notifier<ChatState> {
2469 );
2470 _latestStreamingMessage = streamingAssistantMessage;
2471 _chunkCount++;
2357 - if (!_isInMemoryChat &&
2472 + if (!streamInMemory &&
2473 _shouldCheckpointSave(streamingAssistantMessage)) {
2474 _saveService?.enqueue(streamingAssistantMessage);
2475 _updateSavedMetrics(streamingAssistantMessage);
@@ -2374,7 +2489,7 @@ class ChatNotifier extends Notifier<ChatState> {
2489 );
2490 _latestStreamingMessage = streamingAssistantMessage;
2491 _chunkCount++;
2377 - if (!_isInMemoryChat &&
2492 + if (!streamInMemory &&
2493 _shouldCheckpointSave(streamingAssistantMessage)) {
2494 _saveService?.enqueue(streamingAssistantMessage);
2495 _updateSavedMetrics(streamingAssistantMessage);
@@ -2413,7 +2528,7 @@ class ChatNotifier extends Notifier<ChatState> {
2528 clearStreaming: true,
2529 );
2530 }
2416 - ref.read(isStreamingProvider.notifier).setStreaming(false);
2531 + _setGlobalStreaming(false);
2532 ref.read(chatBackgroundServiceProvider).stop();
2533 }
2534 break;
@@ -2456,7 +2571,7 @@ class ChatNotifier extends Notifier<ChatState> {
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(
@@ -2477,8 +2592,10 @@ class ChatNotifier extends Notifier<ChatState> {
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)
@@ -2512,7 +2629,7 @@ class ChatNotifier extends Notifier<ChatState> {
2629 );
2630 }
2631 if (ref.mounted) {
2515 - ref.read(isStreamingProvider.notifier).setStreaming(false);
2632 + _setGlobalStreaming(false);
2633 ref.read(chatBackgroundServiceProvider).stop();
2634 }
2635 },
@@ -2524,7 +2641,7 @@ class ChatNotifier extends Notifier<ChatState> {
2641 errorMessage: e.toString(),
2642 clearStreaming: true,
2643 );
2527 - ref.read(isStreamingProvider.notifier).setStreaming(false);
2644 + _setGlobalStreaming(false);
2645 }
2646 }
2647
@@ -2669,6 +2786,7 @@ class ChatNotifier extends Notifier<ChatState> {
2786 }
2787
2788 Future<void> generateResponseForLastUser() async {
2789 + if (_blockIfGeneratingElsewhere()) return;
2790 if (state.isStreaming || _isRegenerating) return;
2791 _isRegenerating = true;
2792 try {
@@ -2688,6 +2806,7 @@ class ChatNotifier extends Notifier<ChatState> {
2806 }
2807
2808 Future<void> generateAiUserMessage() async {
2809 + if (_blockIfGeneratingElsewhere()) return;
2810 if (state.isStreaming) return;
2811 if (state.messages.isEmpty) return;
2812
@@ -2768,6 +2887,7 @@ class ChatNotifier extends Notifier<ChatState> {
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
@@ -2812,7 +2932,32 @@ class ChatNotifier extends Notifier<ChatState> {
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,
lib/features/chat/providers/model_selection_providers.dart
+20
@@ -15,6 +15,9 @@ import 'package:localmind/features/servers/providers/server_providers.dart';
15 import 'package:localmind/features/on_device/providers/on_device_providers.dart';
16
17 const modelSelectionRequiredMessage = 'You need to select a model first';
18 +const generatingElsewhereMessage =
19 + 'Another chat is still generating a reply. Wait for it to finish or stop '
20 + 'it before sending here.';
21
22 class ActiveChatTarget {
23 final Server? server;
@@ -368,3 +371,20 @@ class IsStreamingNotifier extends Notifier<bool> {
371 state = streaming;
372 }
373 }
374 +
375 +/// Id of the conversation whose reply is currently generating, or null.
376 +/// Stays set while the user views another chat, so a reply can keep
377 +/// streaming in the background (#94).
378 +final streamingConversationIdProvider =
379 + NotifierProvider<StreamingConversationIdNotifier, String?>(
380 + StreamingConversationIdNotifier.new,
381 + );
382 +
383 +class StreamingConversationIdNotifier extends Notifier<String?> {
384 + @override
385 + String? build() => null;
386 +
387 + void set(String? conversationId) {
388 + state = conversationId;
389 + }
390 +}
lib/features/chat/views/components/background_generation_notice.dart new
+76
@@ -0,0 +1,76 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:hugeicons/hugeicons.dart';
4 +import 'package:localmind/l10n/app_localizations.dart';
5 +
6 +import '../../../conversations/providers/conversation_providers.dart' as conv;
7 +import '../../providers/chat_providers.dart';
8 +
9 +/// Shown above the input while another chat's reply is still generating in
10 +/// the background (#94). Tapping it opens that chat.
11 +class BackgroundGenerationNotice extends ConsumerWidget {
12 + const BackgroundGenerationNotice({super.key, required this.conversationId});
13 +
14 + final String conversationId;
15 +
16 + @override
17 + Widget build(BuildContext context, WidgetRef ref) {
18 + final l10n = AppLocalizations.of(context)!;
19 + final theme = Theme.of(context);
20 + final conversation = ref.watch(
21 + conv.conversationsProvider.select(
22 + (value) =>
23 + value.value?.where((c) => c.id == conversationId).firstOrNull,
24 + ),
25 + );
26 + final title = conversation?.title.trim();
27 +
28 + return Padding(
29 + padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
30 + child: Material(
31 + color: theme.colorScheme.primary.withValues(alpha: 0.1),
32 + borderRadius: BorderRadius.circular(12),
33 + child: InkWell(
34 + borderRadius: BorderRadius.circular(12),
35 + onTap: conversation == null
36 + ? null
37 + : () => ref
38 + .read(chatProvider.notifier)
39 + .loadConversation(conversation),
40 + child: Padding(
41 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
42 + child: Row(
43 + children: [
44 + SizedBox(
45 + width: 14,
46 + height: 14,
47 + child: CircularProgressIndicator(
48 + strokeWidth: 2,
49 + color: theme.colorScheme.primary,
50 + ),
51 + ),
52 + const SizedBox(width: 10),
53 + Expanded(
54 + child: Text(
55 + title == null || title.isEmpty
56 + ? l10n.background_generation_notice_untitled
57 + : l10n.background_generation_notice(title),
58 + maxLines: 2,
59 + overflow: TextOverflow.ellipsis,
60 + style: theme.textTheme.bodySmall,
61 + ),
62 + ),
63 + if (conversation != null)
64 + HugeIcon(
65 + icon: HugeIcons.strokeRoundedArrowRight01,
66 + size: 16,
67 + color: theme.colorScheme.primary,
68 + ),
69 + ],
70 + ),
71 + ),
72 + ),
73 + ),
74 + );
75 + }
76 +}
lib/features/chat/views/components/chat_bottom_bar.dart
+12
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3 import 'package:localmind/core/models/enums.dart';
4 import 'package:localmind/core/providers/app_providers.dart';
5 +import 'package:localmind/features/chat/views/components/background_generation_notice.dart';
6 import 'package:localmind/features/chat/views/components/chat_input_bar.dart';
7 import 'package:localmind/features/chat/views/components/top_bar/smart_reply_chips.dart';
8 import 'package:localmind/features/chat/providers/chat_providers.dart';
@@ -36,6 +37,12 @@ class ChatBottomBar extends ConsumerWidget {
37 final streamingId = ref.watch(
38 chatProvider.select((s) => s.streamingMessage?.id),
39 );
40 + // Another chat's reply is still generating in the background (#94).
41 + final generatingConversationId = ref.watch(streamingConversationIdProvider);
42 + final activeConversationId = ref.watch(conv.activeConversationIdProvider);
43 + final generatingElsewhere =
44 + generatingConversationId != null &&
45 + generatingConversationId != activeConversationId;
46
47 int totalTokenCount = 0;
48 if (hasActiveConv && dbTokenCount != null && dbTokenCount > 0) {
@@ -84,9 +91,14 @@ class ChatBottomBar extends ConsumerWidget {
91 const SizedBox(height: 4),
92 const SmartReplyChipsWrapper(),
93 ],
94 + if (generatingElsewhere)
95 + BackgroundGenerationNotice(
96 + conversationId: generatingConversationId,
97 + ),
98 ChatInputBar(
99 focusNode: inputFocusNode,
100 isStreaming: isStreaming,
101 + sendBlocked: generatingElsewhere,
102 keyboardIncognito: keyboardIncognito,
103 onSend: (message, {attachments}) {
104 ref
lib/features/chat/views/components/chat_input_bar.dart
+16 -2
@@ -41,6 +41,8 @@ class ChatInputBar extends ConsumerStatefulWidget {
41
42 this.enabled = true,
43
44 + this.sendBlocked = false,
45 +
46 this.isStreaming = false,
47
48 this.focusNode,
@@ -58,6 +60,10 @@ class ChatInputBar extends ConsumerStatefulWidget {
60
61 final bool enabled;
62
63 + /// Typing stays possible, but a reply can't be requested yet (another
64 + /// chat's reply is still generating in the background, #94).
65 + final bool sendBlocked;
66 +
67 final bool isStreaming;
68
69 final FocusNode? focusNode;
@@ -485,6 +491,8 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
491 final text = _controller.text.trim();
492
493 if (text.isEmpty && _attachedFiles.isEmpty) return;
494 + // Keep the draft instead of clearing it for a send that can't start.
495 + if (widget.sendBlocked && !_sendAsAssistant) return;
496
497 if (!_ensureChatTarget()) return;
498
@@ -556,7 +564,12 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
564 }
565
566 Future<void> _handleGenerateAiUser() async {
559 - if (_isGeneratingAiUser || widget.isStreaming || !widget.enabled) return;
567 + if (_isGeneratingAiUser ||
568 + widget.isStreaming ||
569 + widget.sendBlocked ||
570 + !widget.enabled) {
571 + return;
572 + }
573 setState(() => _isGeneratingAiUser = true);
574 try {
575 await ref.read(chatProvider.notifier).generateAiUserMessage();
@@ -865,7 +878,8 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
878 widget.enabled &&
879 isConnected &&
880 (_controller.text.trim().isNotEmpty || _attachedFiles.isNotEmpty) &&
868 - !widget.isStreaming;
881 + !widget.isStreaming &&
882 + (!widget.sendBlocked || _sendAsAssistant);
883
884 return SafeArea(
885 top: false,
lib/features/conversations/views/components/conversation_list.dart
+10 -3
@@ -28,6 +28,7 @@ class ConversationList extends ConsumerWidget {
28 @override
29 Widget build(BuildContext context, WidgetRef ref) {
30 final l10n = AppLocalizations.of(context)!;
31 + final generatingConversationId = ref.watch(streamingConversationIdProvider);
32 final selectionMode = ref.watch(historySelectionModeProvider);
33 final selectedIds = ref.watch(historySelectedIdsProvider);
34 final sectionOrder = [
@@ -73,6 +74,7 @@ class ConversationList extends ConsumerWidget {
74 isActive: activeConversation?.id == conversation.id,
75 selectionMode: selectionMode,
76 isSelected: selectedIds.contains(conversation.id),
77 + isGenerating: generatingConversationId == conversation.id,
78 onEnterSelectionMode: () {
79 ref.read(historySelectionModeProvider.notifier).enable();
80 ref
@@ -163,11 +165,16 @@ class ConversationList extends ConsumerWidget {
165 child: Text(l10n.cancel),
166 ),
167 TextButton(
166 - onPressed: () {
167 - ref
168 + onPressed: () async {
169 + Navigator.pop(context);
170 + // Stop a reply still generating for this chat first so it
171 + // doesn't write messages into a deleted conversation.
172 + await ref
173 + .read(chatProvider.notifier)
174 + .cancelGenerationFor(conversation.id);
175 + await ref
176 .read(conversationsProvider.notifier)
177 .deleteConversation(conversation.id);
170 - Navigator.pop(context);
178 },
179 style: TextButton.styleFrom(foregroundColor: Colors.red),
180 child: Text(l10n.delete),
lib/features/conversations/views/components/conversation_tile.dart
+18
@@ -20,8 +20,12 @@ class ConversationTile extends StatelessWidget {
20 this.selectionMode = false,
21 this.isSelected = false,
22 this.onEnterSelectionMode,
23 + this.isGenerating = false,
24 });
25
26 + /// A reply for this chat is still generating (possibly in the background).
27 + final bool isGenerating;
28 +
29 final Conversation conversation;
30 final bool isActive;
31 final VoidCallback onTap;
@@ -111,6 +115,20 @@ class ConversationTile extends StatelessWidget {
115 children: [
116 if (selectionMode)
117 Checkbox(value: isSelected, onChanged: (_) => onTap())
118 + else if (isGenerating)
119 + SizedBox(
120 + width: 20,
121 + height: 20,
122 + child: Padding(
123 + padding: const EdgeInsets.all(2),
124 + child: CircularProgressIndicator(
125 + strokeWidth: 2,
126 + color: isDark
127 + ? AppColors.darkAccent
128 + : AppColors.lightAccent,
129 + ),
130 + ),
131 + )
132 else
133 HugeIcon(
134 icon: conversation.isPinned
lib/features/settings/views/settings_screen.dart
+14 -2
@@ -17,6 +17,7 @@ import '../../../core/services/app_haptics.dart';
17 import '../../../core/services/crash_report_service.dart';
18 import '../../../core/theme/app_theme.dart';
19 import '../../../core/utils/system_insets.dart';
20 +import '../../chat/providers/chat_providers.dart';
21 import '../../chat/utils/image_upload_utils.dart';
22 import '../../conversations/providers/conversation_providers.dart';
23 import '../../models/data/models/model_info.dart';
@@ -508,8 +509,19 @@ class SettingsViews extends ConsumerWidget {
509 _DangerousAction(
510 label: l10n.delete_all_conversations,
511 icon: HugeIcons.strokeRoundedDelete01,
511 - onConfirm: () =>
512 - ref.read(conversationsProvider.notifier).deleteAll(),
512 + onConfirm: () async {
513 + final generatingId = ref.read(
514 + streamingConversationIdProvider,
515 + );
516 + if (generatingId != null) {
517 + await ref
518 + .read(chatProvider.notifier)
519 + .cancelGenerationFor(generatingId);
520 + }
521 + await ref
522 + .read(conversationsProvider.notifier)
523 + .deleteAll();
524 + },
525 ),
526 _DangerousAction(
527 label: l10n.reset_settings_defaults,
lib/l10n/app_ar.arb
+3 -1
@@ -952,5 +952,7 @@
952 "send_sampling_params_desc": "أوقفه لمزوّدي الخدمة أو النماذج التي ترفض هذه المعلمة (مثل بعض نماذج الاستدلال). ينطبق على كل المحادثات مع الخوادم البعيدة.",
953 "default_system_prompt": "موجّه النظام الافتراضي",
954 "default_system_prompt_desc": "يُستخدم في المحادثات التي لا تحتوي على شخصية ولا موجّه نظام خاص بها.",
955 - "default_system_prompt_hint": "أنت مساعد مفيد…"
955 + "default_system_prompt_hint": "أنت مساعد مفيد…",
956 + "background_generation_notice": "لا يزال الرد جاريًا في \"{title}\". يمكنك الإرسال هنا بعد انتهائه. اضغط للفتح.",
957 + "background_generation_notice_untitled": "لا تزال محادثة أخرى تُنشئ ردًا. يمكنك الإرسال هنا بعد انتهائها. اضغط للفتح."
958 }
lib/l10n/app_bn.arb
+3 -1
@@ -948,5 +948,7 @@
948 "send_sampling_params_desc": "যে প্রোভাইডার বা মডেল এই প্যারামিটার গ্রহণ করে না (যেমন কিছু রিজনিং মডেল) তাদের জন্য বন্ধ করুন। রিমোট সার্ভারের সব চ্যাটে প্রযোজ্য।",
949 "default_system_prompt": "ডিফল্ট সিস্টেম প্রম্পট",
950 "default_system_prompt_desc": "যে চ্যাটে কোনো পারসোনা বা নিজস্ব সিস্টেম প্রম্পট নেই সেখানে ব্যবহৃত হয়।",
951 - "default_system_prompt_hint": "তুমি একজন সহায়ক অ্যাসিস্ট্যান্ট…"
951 + "default_system_prompt_hint": "তুমি একজন সহায়ক অ্যাসিস্ট্যান্ট…",
952 + "background_generation_notice": "\"{title}\"-এ এখনও উত্তর তৈরি হচ্ছে। শেষ হলে এখানে পাঠাতে পারবেন। খুলতে ট্যাপ করুন।",
953 + "background_generation_notice_untitled": "অন্য একটি চ্যাটে এখনও উত্তর তৈরি হচ্ছে। শেষ হলে এখানে পাঠাতে পারবেন। খুলতে ট্যাপ করুন।"
954 }
lib/l10n/app_de.arb
+3 -1
@@ -945,5 +945,7 @@
945 "send_sampling_params_desc": "Für Anbieter oder Modelle deaktivieren, die diesen Parameter ablehnen (z. B. einige Reasoning-Modelle). Gilt für alle Chats mit Remote-Servern.",
946 "default_system_prompt": "Standard-Systemprompt",
947 "default_system_prompt_desc": "Wird für Chats ohne Persona und ohne eigenen Systemprompt verwendet.",
948 - "default_system_prompt_hint": "Du bist ein hilfreicher Assistent…"
948 + "default_system_prompt_hint": "Du bist ein hilfreicher Assistent…",
949 + "background_generation_notice": "Antwort in „{title}“ läuft noch. Du kannst hier senden, sobald sie fertig ist. Tippen zum Öffnen.",
950 + "background_generation_notice_untitled": "Ein anderer Chat antwortet noch. Du kannst hier senden, sobald er fertig ist. Tippen zum Öffnen."
951 }
lib/l10n/app_en.arb
+4 -1
@@ -3469,5 +3469,8 @@
3469 "send_sampling_params_desc": "Turn off for providers or models that reject this parameter (for example some reasoning models). Applies to all chats with remote servers.",
3470 "default_system_prompt": "Default system prompt",
3471 "default_system_prompt_desc": "Used for chats that have no persona and no chat-specific system prompt.",
3472 - "default_system_prompt_hint": "You are a helpful assistant…"
3472 + "default_system_prompt_hint": "You are a helpful assistant…",
3473 + "background_generation_notice": "Still replying in \"{title}\". You can send here once it finishes. Tap to open.",
3474 + "@background_generation_notice": {"description": "Notice above the input while another chat's reply generates in the background", "placeholders": {"title": {"type": "String"}}},
3475 + "background_generation_notice_untitled": "Another chat is still replying. You can send here once it finishes. Tap to open."
3476 }
lib/l10n/app_es.arb
+3 -1
@@ -948,5 +948,7 @@
948 "send_sampling_params_desc": "Desactívalo para proveedores o modelos que rechazan este parámetro (por ejemplo, algunos modelos de razonamiento). Se aplica a todos los chats con servidores remotos.",
949 "default_system_prompt": "Prompt de sistema predeterminado",
950 "default_system_prompt_desc": "Se usa en chats sin persona ni prompt de sistema propio.",
951 - "default_system_prompt_hint": "Eres un asistente útil…"
951 + "default_system_prompt_hint": "Eres un asistente útil…",
952 + "background_generation_notice": "Aún respondiendo en \"{title}\". Podrás enviar aquí cuando termine. Toca para abrir.",
953 + "background_generation_notice_untitled": "Otro chat aún está respondiendo. Podrás enviar aquí cuando termine. Toca para abrir."
954 }
lib/l10n/app_fr.arb
+3 -1
@@ -945,5 +945,7 @@
945 "send_sampling_params_desc": "Désactivez-le pour les fournisseurs ou modèles qui refusent ce paramètre (par exemple certains modèles de raisonnement). S'applique à toutes les discussions avec des serveurs distants.",
946 "default_system_prompt": "Prompt système par défaut",
947 "default_system_prompt_desc": "Utilisé pour les discussions sans persona ni prompt système spécifique.",
948 - "default_system_prompt_hint": "Tu es un assistant serviable…"
948 + "default_system_prompt_hint": "Tu es un assistant serviable…",
949 + "background_generation_notice": "Réponse en cours dans « {title} ». Vous pourrez envoyer ici une fois terminée. Touchez pour ouvrir.",
950 + "background_generation_notice_untitled": "Une autre discussion est encore en train de répondre. Vous pourrez envoyer ici une fois terminée. Touchez pour ouvrir."
951 }
lib/l10n/app_hi.arb
+3 -1
@@ -948,5 +948,7 @@
948 "send_sampling_params_desc": "जो प्रोवाइडर या मॉडल इस पैरामीटर को अस्वीकार करते हैं (जैसे कुछ रीज़निंग मॉडल), उनके लिए बंद करें। रिमोट सर्वर वाली सभी चैट पर लागू।",
949 "default_system_prompt": "डिफ़ॉल्ट सिस्टम प्रॉम्प्ट",
950 "default_system_prompt_desc": "उन चैट के लिए उपयोग होता है जिनमें न कोई पर्सोना है और न ही अपना सिस्टम प्रॉम्प्ट।",
951 - "default_system_prompt_hint": "आप एक सहायक असिस्टेंट हैं…"
951 + "default_system_prompt_hint": "आप एक सहायक असिस्टेंट हैं…",
952 + "background_generation_notice": "\"{title}\" में अभी जवाब बन रहा है। पूरा होने पर आप यहाँ भेज सकेंगे। खोलने के लिए टैप करें।",
953 + "background_generation_notice_untitled": "कोई दूसरी चैट अभी जवाब बना रही है। पूरा होने पर आप यहाँ भेज सकेंगे। खोलने के लिए टैप करें।"
954 }
lib/l10n/app_it.arb
+3 -1
@@ -948,5 +948,7 @@
948 "send_sampling_params_desc": "Disattivalo per provider o modelli che rifiutano questo parametro (ad esempio alcuni modelli di ragionamento). Vale per tutte le chat con server remoti.",
949 "default_system_prompt": "Prompt di sistema predefinito",
950 "default_system_prompt_desc": "Usato per le chat senza persona e senza un prompt di sistema proprio.",
951 - "default_system_prompt_hint": "Sei un assistente disponibile…"
951 + "default_system_prompt_hint": "Sei un assistente disponibile…",
952 + "background_generation_notice": "Risposta ancora in corso in \"{title}\". Potrai inviare qui quando termina. Tocca per aprire.",
953 + "background_generation_notice_untitled": "Un'altra chat sta ancora rispondendo. Potrai inviare qui quando termina. Tocca per aprire."
954 }
lib/l10n/app_ja.arb
+3 -1
@@ -948,5 +948,7 @@
948 "send_sampling_params_desc": "このパラメーターを受け付けないプロバイダーやモデル(一部の推論モデルなど)ではオフにしてください。リモートサーバーとのすべてのチャットに適用されます。",
949 "default_system_prompt": "デフォルトのシステムプロンプト",
950 "default_system_prompt_desc": "ペルソナもチャット固有のシステムプロンプトもないチャットで使用されます。",
951 - "default_system_prompt_hint": "あなたは親切なアシスタントです…"
951 + "default_system_prompt_hint": "あなたは親切なアシスタントです…",
952 + "background_generation_notice": "「{title}」でまだ返信を生成中です。完了するとここで送信できます。タップして開く。",
953 + "background_generation_notice_untitled": "別のチャットでまだ返信を生成中です。完了するとここで送信できます。タップして開く。"
954 }
lib/l10n/app_ko.arb
+3 -1
@@ -959,5 +959,7 @@
959 "send_sampling_params_desc": "이 매개변수를 거부하는 제공업체나 모델(예: 일부 추론 모델)에서는 끄세요. 원격 서버와의 모든 채팅에 적용됩니다.",
960 "default_system_prompt": "기본 시스템 프롬프트",
961 "default_system_prompt_desc": "페르소나와 채팅별 시스템 프롬프트가 없는 채팅에 사용됩니다.",
962 - "default_system_prompt_hint": "당신은 도움이 되는 어시스턴트입니다…"
962 + "default_system_prompt_hint": "당신은 도움이 되는 어시스턴트입니다…",
963 + "background_generation_notice": "\"{title}\"에서 아직 답변을 생성 중입니다. 완료되면 여기서 보낼 수 있습니다. 탭하여 열기.",
964 + "background_generation_notice_untitled": "다른 채팅에서 아직 답변을 생성 중입니다. 완료되면 여기서 보낼 수 있습니다. 탭하여 열기."
965 }
lib/l10n/app_localizations.dart
+12
@@ -5948,6 +5948,18 @@ abstract class AppLocalizations {
5948 /// In en, this message translates to:
5949 /// **'You are a helpful assistant…'**
5950 String get default_system_prompt_hint;
5951 +
5952 + /// Notice above the input while another chat's reply generates in the background
5953 + ///
5954 + /// In en, this message translates to:
5955 + /// **'Still replying in \"{title}\". You can send here once it finishes. Tap to open.'**
5956 + String background_generation_notice(String title);
5957 +
5958 + /// No description provided for @background_generation_notice_untitled.
5959 + ///
5960 + /// In en, this message translates to:
5961 + /// **'Another chat is still replying. You can send here once it finishes. Tap to open.'**
5962 + String get background_generation_notice_untitled;
5963 }
5964
5965 class _AppLocalizationsDelegate
lib/l10n/app_localizations_ar.dart
+9
@@ -3198,4 +3198,13 @@ class AppLocalizationsAr extends AppLocalizations {
3198
3199 @override
3200 String get default_system_prompt_hint => 'أنت مساعد مفيد…';
3201 +
3202 + @override
3203 + String background_generation_notice(String title) {
3204 + return 'لا يزال الرد جاريًا في \"$title\". يمكنك الإرسال هنا بعد انتهائه. اضغط للفتح.';
3205 + }
3206 +
3207 + @override
3208 + String get background_generation_notice_untitled =>
3209 + 'لا تزال محادثة أخرى تُنشئ ردًا. يمكنك الإرسال هنا بعد انتهائها. اضغط للفتح.';
3210 }
lib/l10n/app_localizations_bn.dart
+9
@@ -3223,4 +3223,13 @@ class AppLocalizationsBn extends AppLocalizations {
3223
3224 @override
3225 String get default_system_prompt_hint => 'তুমি একজন সহায়ক অ্যাসিস্ট্যান্ট…';
3226 +
3227 + @override
3228 + String background_generation_notice(String title) {
3229 + return '\"$title\"-এ এখনও উত্তর তৈরি হচ্ছে। শেষ হলে এখানে পাঠাতে পারবেন। খুলতে ট্যাপ করুন।';
3230 + }
3231 +
3232 + @override
3233 + String get background_generation_notice_untitled =>
3234 + 'অন্য একটি চ্যাটে এখনও উত্তর তৈরি হচ্ছে। শেষ হলে এখানে পাঠাতে পারবেন। খুলতে ট্যাপ করুন।';
3235 }
lib/l10n/app_localizations_de.dart
+9
@@ -3213,4 +3213,13 @@ class AppLocalizationsDe extends AppLocalizations {
3213
3214 @override
3215 String get default_system_prompt_hint => 'Du bist ein hilfreicher Assistent…';
3216 +
3217 + @override
3218 + String background_generation_notice(String title) {
3219 + return 'Antwort in „$title“ läuft noch. Du kannst hier senden, sobald sie fertig ist. Tippen zum Öffnen.';
3220 + }
3221 +
3222 + @override
3223 + String get background_generation_notice_untitled =>
3224 + 'Ein anderer Chat antwortet noch. Du kannst hier senden, sobald er fertig ist. Tippen zum Öffnen.';
3225 }
lib/l10n/app_localizations_en.dart
+9
@@ -3201,4 +3201,13 @@ class AppLocalizationsEn extends AppLocalizations {
3201
3202 @override
3203 String get default_system_prompt_hint => 'You are a helpful assistant…';
3204 +
3205 + @override
3206 + String background_generation_notice(String title) {
3207 + return 'Still replying in \"$title\". You can send here once it finishes. Tap to open.';
3208 + }
3209 +
3210 + @override
3211 + String get background_generation_notice_untitled =>
3212 + 'Another chat is still replying. You can send here once it finishes. Tap to open.';
3213 }
lib/l10n/app_localizations_es.dart
+9
@@ -3227,4 +3227,13 @@ class AppLocalizationsEs extends AppLocalizations {
3227
3228 @override
3229 String get default_system_prompt_hint => 'Eres un asistente útil…';
3230 +
3231 + @override
3232 + String background_generation_notice(String title) {
3233 + return 'Aún respondiendo en \"$title\". Podrás enviar aquí cuando termine. Toca para abrir.';
3234 + }
3235 +
3236 + @override
3237 + String get background_generation_notice_untitled =>
3238 + 'Otro chat aún está respondiendo. Podrás enviar aquí cuando termine. Toca para abrir.';
3239 }
lib/l10n/app_localizations_fr.dart
+9
@@ -3216,4 +3216,13 @@ class AppLocalizationsFr extends AppLocalizations {
3216
3217 @override
3218 String get default_system_prompt_hint => 'Tu es un assistant serviable…';
3219 +
3220 + @override
3221 + String background_generation_notice(String title) {
3222 + return 'Réponse en cours dans « $title ». Vous pourrez envoyer ici une fois terminée. Touchez pour ouvrir.';
3223 + }
3224 +
3225 + @override
3226 + String get background_generation_notice_untitled =>
3227 + 'Une autre discussion est encore en train de répondre. Vous pourrez envoyer ici une fois terminée. Touchez pour ouvrir.';
3228 }
lib/l10n/app_localizations_hi.dart
+9
@@ -3213,4 +3213,13 @@ class AppLocalizationsHi extends AppLocalizations {
3213
3214 @override
3215 String get default_system_prompt_hint => 'आप एक सहायक असिस्टेंट हैं…';
3216 +
3217 + @override
3218 + String background_generation_notice(String title) {
3219 + return '\"$title\" में अभी जवाब बन रहा है। पूरा होने पर आप यहाँ भेज सकेंगे। खोलने के लिए टैप करें।';
3220 + }
3221 +
3222 + @override
3223 + String get background_generation_notice_untitled =>
3224 + 'कोई दूसरी चैट अभी जवाब बना रही है। पूरा होने पर आप यहाँ भेज सकेंगे। खोलने के लिए टैप करें।';
3225 }
lib/l10n/app_localizations_it.dart
+9
@@ -3229,4 +3229,13 @@ class AppLocalizationsIt extends AppLocalizations {
3229
3230 @override
3231 String get default_system_prompt_hint => 'Sei un assistente disponibile…';
3232 +
3233 + @override
3234 + String background_generation_notice(String title) {
3235 + return 'Risposta ancora in corso in \"$title\". Potrai inviare qui quando termina. Tocca per aprire.';
3236 + }
3237 +
3238 + @override
3239 + String get background_generation_notice_untitled =>
3240 + 'Un\'altra chat sta ancora rispondendo. Potrai inviare qui quando termina. Tocca per aprire.';
3241 }
lib/l10n/app_localizations_ja.dart
+9
@@ -3146,4 +3146,13 @@ class AppLocalizationsJa extends AppLocalizations {
3146
3147 @override
3148 String get default_system_prompt_hint => 'あなたは親切なアシスタントです…';
3149 +
3150 + @override
3151 + String background_generation_notice(String title) {
3152 + return '「$title」でまだ返信を生成中です。完了するとここで送信できます。タップして開く。';
3153 + }
3154 +
3155 + @override
3156 + String get background_generation_notice_untitled =>
3157 + '別のチャットでまだ返信を生成中です。完了するとここで送信できます。タップして開く。';
3158 }
lib/l10n/app_localizations_ko.dart
+9
@@ -3128,4 +3128,13 @@ class AppLocalizationsKo extends AppLocalizations {
3128
3129 @override
3130 String get default_system_prompt_hint => '당신은 도움이 되는 어시스턴트입니다…';
3131 +
3132 + @override
3133 + String background_generation_notice(String title) {
3134 + return '\"$title\"에서 아직 답변을 생성 중입니다. 완료되면 여기서 보낼 수 있습니다. 탭하여 열기.';
3135 + }
3136 +
3137 + @override
3138 + String get background_generation_notice_untitled =>
3139 + '다른 채팅에서 아직 답변을 생성 중입니다. 완료되면 여기서 보낼 수 있습니다. 탭하여 열기.';
3140 }
lib/l10n/app_localizations_pt.dart
+9
@@ -3244,4 +3244,13 @@ class AppLocalizationsPt extends AppLocalizations {
3244
3245 @override
3246 String get default_system_prompt_hint => 'Você é um assistente prestativo…';
3247 +
3248 + @override
3249 + String background_generation_notice(String title) {
3250 + return 'Ainda respondendo em \"$title\". Você poderá enviar aqui quando terminar. Toque para abrir.';
3251 + }
3252 +
3253 + @override
3254 + String get background_generation_notice_untitled =>
3255 + 'Outro chat ainda está respondendo. Você poderá enviar aqui quando terminar. Toque para abrir.';
3256 }
lib/l10n/app_localizations_ru.dart
+9
@@ -3226,4 +3226,13 @@ class AppLocalizationsRu extends AppLocalizations {
3226
3227 @override
3228 String get default_system_prompt_hint => 'Ты полезный ассистент…';
3229 +
3230 + @override
3231 + String background_generation_notice(String title) {
3232 + return 'Ответ в «$title» ещё генерируется. Отправить здесь можно будет после завершения. Нажмите, чтобы открыть.';
3233 + }
3234 +
3235 + @override
3236 + String get background_generation_notice_untitled =>
3237 + 'Другой чат ещё генерирует ответ. Отправить здесь можно будет после завершения. Нажмите, чтобы открыть.';
3238 }
lib/l10n/app_localizations_tr.dart
+9
@@ -3233,4 +3233,13 @@ class AppLocalizationsTr extends AppLocalizations {
3233
3234 @override
3235 String get default_system_prompt_hint => 'Yardımsever bir asistansın…';
3236 +
3237 + @override
3238 + String background_generation_notice(String title) {
3239 + return '\"$title\" sohbetinde yanıt hâlâ oluşturuluyor. Bitince buradan gönderebilirsiniz. Açmak için dokunun.';
3240 + }
3241 +
3242 + @override
3243 + String get background_generation_notice_untitled =>
3244 + 'Başka bir sohbet hâlâ yanıt oluşturuyor. Bitince buradan gönderebilirsiniz. Açmak için dokunun.';
3245 }
lib/l10n/app_localizations_zh.dart
+18
@@ -3119,6 +3119,15 @@ class AppLocalizationsZh extends AppLocalizations {
3119
3120 @override
3121 String get default_system_prompt_hint => '你是一个乐于助人的助手……';
3122 +
3123 + @override
3124 + String background_generation_notice(String title) {
3125 + return '“$title”仍在生成回复。完成后即可在此发送。点按打开。';
3126 + }
3127 +
3128 + @override
3129 + String get background_generation_notice_untitled =>
3130 + '另一个对话仍在生成回复。完成后即可在此发送。点按打开。';
3131 }
3132
3133 /// The translations for Chinese, as used in Taiwan (`zh_TW`).
@@ -6142,4 +6151,13 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
6151
6152 @override
6153 String get default_system_prompt_hint => '你是一個樂於助人的助理……';
6154 +
6155 + @override
6156 + String background_generation_notice(String title) {
6157 + return '「$title」仍在產生回覆。完成後即可在此傳送。點按開啟。';
6158 + }
6159 +
6160 + @override
6161 + String get background_generation_notice_untitled =>
6162 + '另一個對話仍在產生回覆。完成後即可在此傳送。點按開啟。';
6163 }
lib/l10n/app_pt.arb
+3 -1
@@ -959,5 +959,7 @@
959 "send_sampling_params_desc": "Desative para provedores ou modelos que rejeitam este parâmetro (por exemplo, alguns modelos de raciocínio). Aplica-se a todos os chats com servidores remotos.",
960 "default_system_prompt": "Prompt de sistema padrão",
961 "default_system_prompt_desc": "Usado em chats sem persona e sem prompt de sistema próprio.",
962 - "default_system_prompt_hint": "Você é um assistente prestativo…"
962 + "default_system_prompt_hint": "Você é um assistente prestativo…",
963 + "background_generation_notice": "Ainda respondendo em \"{title}\". Você poderá enviar aqui quando terminar. Toque para abrir.",
964 + "background_generation_notice_untitled": "Outro chat ainda está respondendo. Você poderá enviar aqui quando terminar. Toque para abrir."
965 }
lib/l10n/app_ru.arb
+3 -1
@@ -945,5 +945,7 @@
945 "send_sampling_params_desc": "Отключите для провайдеров или моделей, которые не принимают этот параметр (например, некоторых моделей рассуждения). Действует для всех чатов с удалёнными серверами.",
946 "default_system_prompt": "Системный промпт по умолчанию",
947 "default_system_prompt_desc": "Используется в чатах без персоны и без собственного системного промпта.",
948 - "default_system_prompt_hint": "Ты полезный ассистент…"
948 + "default_system_prompt_hint": "Ты полезный ассистент…",
949 + "background_generation_notice": "Ответ в «{title}» ещё генерируется. Отправить здесь можно будет после завершения. Нажмите, чтобы открыть.",
950 + "background_generation_notice_untitled": "Другой чат ещё генерирует ответ. Отправить здесь можно будет после завершения. Нажмите, чтобы открыть."
951 }
lib/l10n/app_tr.arb
+3 -1
@@ -945,5 +945,7 @@
945 "send_sampling_params_desc": "Bu parametreyi reddeden sağlayıcılar veya modeller için kapatın (örneğin bazı akıl yürütme modelleri). Uzak sunuculardaki tüm sohbetler için geçerlidir.",
946 "default_system_prompt": "Varsayılan sistem istemi",
947 "default_system_prompt_desc": "Personası ve sohbete özel sistem istemi olmayan sohbetlerde kullanılır.",
948 - "default_system_prompt_hint": "Yardımsever bir asistansın…"
948 + "default_system_prompt_hint": "Yardımsever bir asistansın…",
949 + "background_generation_notice": "\"{title}\" sohbetinde yanıt hâlâ oluşturuluyor. Bitince buradan gönderebilirsiniz. Açmak için dokunun.",
950 + "background_generation_notice_untitled": "Başka bir sohbet hâlâ yanıt oluşturuyor. Bitince buradan gönderebilirsiniz. Açmak için dokunun."
951 }
lib/l10n/app_zh.arb
+3 -1
@@ -948,5 +948,7 @@
948 "send_sampling_params_desc": "对于拒绝此参数的服务商或模型(例如部分推理模型)请关闭。适用于所有远程服务器的对话。",
949 "default_system_prompt": "默认系统提示词",
950 "default_system_prompt_desc": "用于没有角色且没有专属系统提示词的对话。",
951 - "default_system_prompt_hint": "你是一个乐于助人的助手……"
951 + "default_system_prompt_hint": "你是一个乐于助人的助手……",
952 + "background_generation_notice": "“{title}”仍在生成回复。完成后即可在此发送。点按打开。",
953 + "background_generation_notice_untitled": "另一个对话仍在生成回复。完成后即可在此发送。点按打开。"
954 }
lib/l10n/app_zh_TW.arb
+3 -1
@@ -948,5 +948,7 @@
948 "send_sampling_params_desc": "對於拒絕此參數的服務商或模型(例如部分推理模型)請關閉。適用於所有遠端伺服器的對話。",
949 "default_system_prompt": "預設系統提示詞",
950 "default_system_prompt_desc": "用於沒有角色且沒有專屬系統提示詞的對話。",
951 - "default_system_prompt_hint": "你是一個樂於助人的助理……"
951 + "default_system_prompt_hint": "你是一個樂於助人的助理……",
952 + "background_generation_notice": "「{title}」仍在產生回覆。完成後即可在此傳送。點按開啟。",
953 + "background_generation_notice_untitled": "另一個對話仍在產生回覆。完成後即可在此傳送。點按開啟。"
954 }
test/features/chat/providers/chat_background_generation_test.dart new
+351
@@ -0,0 +1,351 @@
1 +import 'dart:async';
2 +
3 +import 'package:flutter_riverpod/flutter_riverpod.dart';
4 +import 'package:flutter_test/flutter_test.dart';
5 +import 'package:localmind/core/models/enums.dart';
6 +import 'package:localmind/core/providers/app_providers.dart';
7 +import 'package:localmind/core/providers/chat_background_service_provider.dart';
8 +import 'package:localmind/core/providers/storage_providers.dart';
9 +import 'package:localmind/core/services/chat_background_service.dart';
10 +import 'package:localmind/features/chat/data/chat_service.dart';
11 +import 'package:localmind/features/chat/data/models/chat_parameters.dart';
12 +import 'package:localmind/features/chat/data/models/message.dart';
13 +import 'package:localmind/features/chat/data/tools/tool_definition.dart';
14 +import 'package:localmind/features/chat/data/tools/tool_registry.dart';
15 +import 'package:localmind/features/chat/providers/chat_mcp_providers.dart';
16 +import 'package:localmind/features/chat/providers/chat_notifier.dart';
17 +import 'package:localmind/features/chat/providers/chat_params_providers.dart';
18 +import 'package:localmind/features/chat/providers/chat_service_providers.dart';
19 +import 'package:localmind/features/chat/providers/model_selection_providers.dart';
20 +import 'package:localmind/features/chat/providers/tooling_providers.dart';
21 +import 'package:localmind/features/conversations/data/models/conversation.dart';
22 +import 'package:localmind/features/conversations/providers/conversation_providers.dart'
23 + as conv;
24 +import 'package:localmind/features/servers/data/models/server.dart';
25 +import 'package:localmind/features/servers/providers/server_providers.dart';
26 +import 'package:localmind/features/settings/data/models/app_settings.dart';
27 +import 'package:localmind/features/voice_mode/providers/voice_mode_provider.dart';
28 +import 'package:shared_preferences/shared_preferences.dart';
29 +
30 +void main() {
31 + TestWidgetsFlutterBinding.ensureInitialized();
32 +
33 + late _ControllableChatService chatService;
34 + late ProviderContainer container;
35 + late _BackgroundTestChatNotifier notifier;
36 +
37 + setUp(() async {
38 + SharedPreferences.setMockInitialValues({});
39 + final prefs = await SharedPreferences.getInstance();
40 + chatService = _ControllableChatService();
41 + container = ProviderContainer(
42 + overrides: [
43 + sharedPreferencesProvider.overrideWithValue(prefs),
44 + activeServerProvider.overrideWith(_RemoteServerNotifier.new),
45 + activeChatTargetProvider.overrideWithValue(
46 + ActiveChatTarget(
47 + server: _server,
48 + selectedModel: null,
49 + effectiveModelId: 'model',
50 + modelLabel: 'Model',
51 + ),
52 + ),
53 + chatProvider.overrideWith(_BackgroundTestChatNotifier.new),
54 + chatServiceProvider.overrideWithValue(chatService),
55 + chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
56 + chatMcpConfigProvider.overrideWith(_DisabledMcpNotifier.new),
57 + toolRegistryProvider.overrideWithValue(
58 + ToolRegistry(providers: const []),
59 + ),
60 + chatBackgroundServiceProvider.overrideWithValue(
61 + _TestChatBackgroundService(),
62 + ),
63 + settingsProvider.overrideWith(_TestSettingsNotifier.new),
64 + voiceModeProvider.overrideWith(_IdleVoiceModeNotifier.new),
65 + conv.conversationsProvider.overrideWith(_FakeConversationsNotifier.new),
66 + ],
67 + );
68 + await container.read(conv.conversationsProvider.future);
69 + notifier =
70 + container.read(chatProvider.notifier) as _BackgroundTestChatNotifier;
71 + });
72 +
73 + tearDown(() => container.dispose());
74 +
75 + Future<void> startReplyInA() async {
76 + await notifier.loadConversation(_conversationA);
77 + await notifier.continueFromMessage('a-assistant');
78 + await _pump();
79 + expect(chatService.controllers, hasLength(1));
80 + chatService.emit('Hello');
81 + await _pump();
82 + }
83 +
84 + test('switching chats keeps the reply generating (#94)', () async {
85 + await startReplyInA();
86 +
87 + await notifier.loadConversation(_conversationB);
88 + await _pump();
89 +
90 + expect(chatService.cancelCount, 0);
91 + expect(container.read(chatProvider).isStreaming, isFalse);
92 + expect(container.read(chatProvider).messages.first.id, 'b-user');
93 + expect(container.read(streamingConversationIdProvider), 'conversation-a');
94 + expect(container.read(isStreamingProvider), isTrue);
95 +
96 + chatService.emit(' world');
97 + await chatService.finish();
98 + await _pump();
99 +
100 + final saved = notifier.saved.lastWhere((m) => m.id == 'a-assistant');
101 + expect(saved.content, contains('world'));
102 + expect(saved.conversationId, 'conversation-a');
103 + expect(saved.status, MessageStatus.complete);
104 + // Chat B's view is untouched by the background completion.
105 + expect(container.read(chatProvider).messages.first.id, 'b-user');
106 + expect(container.read(streamingConversationIdProvider), isNull);
107 + expect(container.read(isStreamingProvider), isFalse);
108 + });
109 +
110 + test(
111 + 'returning to the generating chat reattaches to the live reply',
112 + () async {
113 + await startReplyInA();
114 + await notifier.loadConversation(_conversationB);
115 + await notifier.loadConversation(_conversationA);
116 + await _pump();
117 +
118 + expect(chatService.cancelCount, 0);
119 + final state = container.read(chatProvider);
120 + expect(state.isStreaming, isTrue);
121 + expect(state.streamingMessage?.id, 'a-assistant');
122 +
123 + chatService.emit(' again');
124 + await Future<void>.delayed(const Duration(milliseconds: 120));
125 + expect(
126 + container.read(chatProvider).streamingMessage?.content,
127 + contains('again'),
128 + );
129 +
130 + await chatService.finish();
131 + await _pump();
132 + expect(container.read(chatProvider).isStreaming, isFalse);
133 + },
134 + );
135 +
136 + test('sending in another chat is blocked while a reply runs', () async {
137 + await startReplyInA();
138 + await notifier.loadConversation(_conversationB);
139 +
140 + await notifier.sendMessage('new question');
141 +
142 + expect(
143 + container.read(chatProvider).errorMessage,
144 + generatingElsewhereMessage,
145 + );
146 + expect(chatService.controllers, hasLength(1));
147 + expect(chatService.cancelCount, 0);
148 + });
149 +
150 + test('a second switch does not cancel the background reply', () async {
151 + await startReplyInA();
152 + await notifier.loadConversation(_conversationB);
153 + await notifier.startNewConversation();
154 + await _pump();
155 +
156 + expect(chatService.cancelCount, 0);
157 + expect(container.read(streamingConversationIdProvider), 'conversation-a');
158 + });
159 +
160 + test('cancelGenerationFor stops a background reply', () async {
161 + await startReplyInA();
162 + await notifier.loadConversation(_conversationB);
163 +
164 + await notifier.cancelGenerationFor('conversation-a');
165 +
166 + expect(chatService.cancelCount, 1);
167 + expect(container.read(streamingConversationIdProvider), isNull);
168 + expect(container.read(isStreamingProvider), isFalse);
169 + });
170 +}
171 +
172 +Future<void> _pump() async {
173 + for (var i = 0; i < 5; i++) {
174 + await Future<void>.delayed(Duration.zero);
175 + }
176 +}
177 +
178 +final _server = Server(
179 + id: 'remote',
180 + name: 'Remote',
181 + type: ServerType.ollama,
182 + host: '127.0.0.1',
183 + port: 11434,
184 + createdAt: DateTime.utc(2026, 9, 28),
185 + lastConnectedAt: DateTime.utc(2026, 9, 28),
186 + status: ConnectionStatus.connected,
187 +);
188 +
189 +final _conversationA = Conversation(
190 + id: 'conversation-a',
191 + title: 'Chat A',
192 + createdAt: DateTime.utc(2026, 9, 28),
193 + updatedAt: DateTime.utc(2026, 9, 28),
194 +);
195 +
196 +final _conversationB = Conversation(
197 + id: 'conversation-b',
198 + title: 'Chat B',
199 + createdAt: DateTime.utc(2026, 9, 28),
200 + updatedAt: DateTime.utc(2026, 9, 28),
201 +);
202 +
203 +Message _message(
204 + String id,
205 + String conversationId,
206 + MessageRole role,
207 + String content, {
208 + String? parentId,
209 + int threadOrder = 0,
210 +}) => Message(
211 + id: id,
212 + conversationId: conversationId,
213 + role: role,
214 + content: content,
215 + createdAt: DateTime.utc(2026, 9, 28),
216 + variantGroupId: id,
217 + threadOrder: threadOrder,
218 + isActiveVariant: true,
219 + parentMessageId: parentId,
220 +);
221 +
222 +final _messagesByConversation = {
223 + 'conversation-a': [
224 + _message('a-user', 'conversation-a', MessageRole.user, 'Hi A'),
225 + _message(
226 + 'a-assistant',
227 + 'conversation-a',
228 + MessageRole.assistant,
229 + 'Hey',
230 + parentId: 'a-user',
231 + threadOrder: 1,
232 + ),
233 + ],
234 + 'conversation-b': [
235 + _message('b-user', 'conversation-b', MessageRole.user, 'Hi B'),
236 + _message(
237 + 'b-assistant',
238 + 'conversation-b',
239 + MessageRole.assistant,
240 + 'Yo',
241 + parentId: 'b-user',
242 + threadOrder: 1,
243 + ),
244 + ],
245 +};
246 +
247 +class _BackgroundTestChatNotifier extends ChatNotifier {
248 + final List<Message> saved = [];
249 +
250 + @override
251 + ChatState build() => const ChatState();
252 +
253 + @override
254 + Future<List<Message>> loadConversationMessages(String conversationId) async {
255 + final base = _messagesByConversation[conversationId] ?? const [];
256 + // Later saves replace the seeded copy, like the database would.
257 + return base.map((message) {
258 + return saved.lastWhere((s) => s.id == message.id, orElse: () => message);
259 + }).toList();
260 + }
261 +
262 + @override
263 + Future<void> persistMessage(Message message) async {
264 + saved.add(message);
265 + }
266 +}
267 +
268 +class _ControllableChatService implements ChatService {
269 + final List<StreamController<ChatResponse>> controllers = [];
270 + int cancelCount = 0;
271 +
272 + void emit(String content) {
273 + controllers.last.add(
274 + ChatResponse(type: ChatResponseType.message, content: content),
275 + );
276 + }
277 +
278 + Future<void> finish() async {
279 + controllers.last.add(const ChatResponse(type: ChatResponseType.done));
280 + await controllers.last.close();
281 + }
282 +
283 + @override
284 + Stream<ChatResponse> sendMessage({
285 + required Server server,
286 + required String modelId,
287 + required List<Message> messages,
288 + required ChatParameters params,
289 + List<McpIntegration>? integrations,
290 + List<ToolDefinition>? tools,
291 + bool continueGeneration = false,
292 + }) {
293 + final controller = StreamController<ChatResponse>();
294 + controllers.add(controller);
295 + return controller.stream;
296 + }
297 +
298 + @override
299 + void cancelStream() {
300 + cancelCount++;
301 + }
302 +}
303 +
304 +class _FakeConversationsNotifier extends conv.ConversationsNotifier {
305 + @override
306 + Future<List<Conversation>> build() async => [_conversationA, _conversationB];
307 +
308 + @override
309 + Future<void> syncConversationStats(
310 + String id, {
311 + required int messageCount,
312 + required int characterCount,
313 + String? preview,
314 + }) async {}
315 +
316 + @override
317 + Future<void> updateTokenCount(String id, int totalTokenCount) async {}
318 +}
319 +
320 +class _RemoteServerNotifier extends ActiveServerNotifier {
321 + @override
322 + Server? build() => _server;
323 +}
324 +
325 +class _DisabledMcpNotifier extends ChatMcpConfigNotifier {
326 + @override
327 + ChatMcpConfig build() => const ChatMcpConfig(enabled: false);
328 +}
329 +
330 +class _TestChatBackgroundService extends ChatBackgroundService {
331 + @override
332 + Future<void> start() async {}
333 +
334 + @override
335 + Future<void> stop() async {}
336 +}
337 +
338 +class _TestSettingsNotifier extends SettingsNotifier {
339 + @override
340 + AppSettings build() => AppSettings(
341 + mcpEnabled: false,
342 + newChatMcpEnabled: false,
343 + resumeLastChat: false,
344 + autoGenerateTitle: false,
345 + );
346 +}
347 +
348 +class _IdleVoiceModeNotifier extends VoiceModeNotifier {
349 + @override
350 + VoiceModeState build() => const VoiceModeState();
351 +}