@tduncan / Cortana / commits / 0d43e44

refactor: support concurrent chat generation with multi-session management and UI indicators

abdulmominsakib committed Sep 30, 2026 at 12:41 UTC 0d43e44cf97104d6f01c987a33c539c8cd3b2460
48 files changed +2210 -492
lib/core/services/crash_report_service.dart
+11
@@ -263,6 +263,8 @@ class CrashReportService {
263 /// - A stale-index `RangeError` in
264 /// `MultiSelectableSelectionContainerDelegate.getSelectionGeometry` when
265 /// selectables change while a selection edge is being updated.
266 + /// - Purely UI-related layout overflows (e.g. `RenderFlex overflowed by ... pixels`)
267 + /// which are visual rendering defects rather than fatal crashes.
268 static bool isBenignFrameworkError(Object error, [StackTrace? stack]) {
269 final errorStr = error.toString();
270 final stackStr = stack?.toString() ?? '';
@@ -280,6 +282,15 @@ class CrashReportService {
282 return true;
283 }
284
285 + // Purely UI-related layout overflows (RenderFlex, RenderParagraph, etc.).
286 + // These are visual clipping issues and must not replace the app with the
287 + // full-screen crash report overlay.
288 + if (errorStr.contains('A RenderFlex overflowed by') ||
289 + errorStr.contains('RenderFlex overflowed') ||
290 + (errorStr.contains('overflowed by') && errorStr.contains('pixels'))) {
291 + return true;
292 + }
293 +
294 return false;
295 }
296
lib/features/chat/providers/chat_notifier.dart
+320 -365
@@ -39,6 +39,7 @@ import 'chat_origin_provider.dart';
39 import 'chat_params_providers.dart';
40 import 'chat_reasoning_providers.dart';
41 import 'chat_service_providers.dart';
42 +import 'generation_session.dart';
43 import 'message_selection_provider.dart';
44 import 'model_selection_providers.dart';
45 import 'tooling_providers.dart';
@@ -119,69 +120,95 @@ final chatProvider = NotifierProvider<ChatNotifier, ChatState>(() {
120 });
121
122 class ChatNotifier extends Notifier<ChatState> {
122 - static const _checkpointChunkThreshold = 20;
123 - static const _checkpointTimeThreshold = Duration(seconds: 2);
124 -
125 - StreamSubscription<ChatResponse>? _streamSubscription;
126 - int _streamGeneration = 0;
127 - Timer? _uiUpdateTimer;
128 - Message? _latestStreamingMessage;
129 - String? _currentConversationId;
130 - int _chunkCount = 0;
131 - DateTime? _lastCheckpointTime;
132 - int _lastSavedContentLength = 0;
133 - int _lastSavedReasoningLength = 0;
134 -
123 MessageSaveService? _saveService;
124 PendingToolApproval? _pendingToolApproval;
125 final Set<String> _titleGenerationsInFlight = <String>{};
126 final Map<String, Future<void>> _titleGenerationFutures =
127 <String, Future<void>>{};
128 bool _pendingTemporaryChat = false;
129 + String? _currentConversationId;
130 String? _ephemeralConversationId;
131 bool _attemptedResume = false;
132 static const _lastActiveConversationKey = 'lastActiveConversationId';
144 - ChatStats? _streamStats;
145 - DateTime? _streamStartTime;
146 - DateTime? _firstTokenTime;
133 bool _useFreshConversationSystemPrompt = false;
134 bool _isRegenerating = false;
135 String? _freshConversationSystemPrompt;
136
137 + /// In-flight replies keyed by conversation id (insertion order = start
138 + /// order). Remote replies run concurrently, one per conversation; at most
139 + /// one on-device reply exists at a time.
140 + final Map<String, GenerationSession> _sessions = {};
141 + bool _backgroundServiceRunning = false;
142 +
143 String? get _activeConversationId =>
144 _currentConversationId ?? _ephemeralConversationId;
145
146 bool get _isInMemoryChat => state.isTemporary;
147
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;
148 + /// Reply generating for the open chat, if any.
149 + GenerationSession? get _activeSession {
150 + final id = _activeConversationId;
151 + return id == null ? null : _sessions[id];
152 + }
153
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;
154 + /// Conversations other than the open chat whose reply is still generating.
155 + Set<String> get generatingElsewhereIds => {
156 + for (final id in _sessions.keys)
157 + if (id != _activeConversationId) id,
158 + };
159
169 - /// True while a reply for another conversation is still generating.
170 - bool get isGeneratingElsewhere =>
171 - _streamConversationId != null &&
172 - _streamConversationId != _activeConversationId;
160 + /// Registers the reply that is about to stream into [conversationId]. A
161 + /// previous session for the same chat (a tool-call follow-up) is replaced.
162 + GenerationSession _beginSession(
163 + String conversationId,
164 + Server server,
165 + ChatService chatService,
166 + ) {
167 + final previous = _sessions[conversationId];
168 + final session = GenerationSession(
169 + conversationId: conversationId,
170 + server: server,
171 + chatService: chatService,
172 + persisted: previous?.persisted ?? !_isInMemoryChat,
173 + );
174 + _sessions[conversationId] = session;
175 + if (previous != null) unawaited(previous.detach());
176 + _publishGenerations();
177 + return session;
178 + }
179
174 - void _setGlobalStreaming(bool streaming) {
175 - if (streaming) {
176 - _streamConversationId = _activeConversationId;
177 - } else {
178 - _streamConversationId = null;
179 - _streamChatService = null;
180 + /// Marks [session] finished. A session that was already replaced by a newer
181 + /// one for the same chat is left alone.
182 + void _endSession(GenerationSession session) {
183 + session.cancelUiTimer();
184 + if (identical(_sessions[session.conversationId], session)) {
185 + _sessions.remove(session.conversationId);
186 }
181 - ref.read(isStreamingProvider.notifier).setStreaming(streaming);
187 + _publishGenerations();
188 + }
189 +
190 + /// Pushes the running replies to the UI providers and keeps the Android
191 + /// foreground service / wakelock alive until the last reply finishes.
192 + void _publishGenerations() {
193 + if (!ref.mounted) return;
194 + ref.read(activeGenerationsProvider.notifier).set({
195 + for (final s in _sessions.values)
196 + s.conversationId: ActiveGeneration(
197 + conversationId: s.conversationId,
198 + isOnDevice: s.isOnDevice,
199 + ),
200 + });
201 + final activeId = _activeConversationId;
202 ref
183 - .read(streamingConversationIdProvider.notifier)
184 - .set(_streamConversationId);
203 + .read(isStreamingProvider.notifier)
204 + .setStreaming(activeId != null && _sessions.containsKey(activeId));
205 +
206 + final shouldRun = _sessions.isNotEmpty;
207 + if (shouldRun != _backgroundServiceRunning) {
208 + _backgroundServiceRunning = shouldRun;
209 + final service = ref.read(chatBackgroundServiceProvider);
210 + unawaited(shouldRun ? service.start() : service.stop());
211 + }
212 }
213
214 /// Whether the open chat's reply may keep streaming after the user leaves
@@ -189,60 +216,56 @@ class ChatNotifier extends Notifier<ChatState> {
216 /// screen, and voice mode drives its own turn-taking.
217 bool _canContinueInBackground() {
218 if (!state.isStreaming || _isInMemoryChat) return false;
192 - if (_streamConversationId == null ||
193 - _streamConversationId != _activeConversationId) {
194 - return false;
195 - }
219 + if (_activeSession == null) return false;
220 if (_pendingToolApproval != null) return false;
221 if (ref.read(chatMcpConfigProvider).enabled) return false;
222 return ref.read(voiceModeProvider).phase == VoiceModePhase.idle;
223 }
224
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);
225 + /// The on-device engine serves one reply at a time. Returns true (and
226 + /// explains why) when the active server is on-device and another chat's
227 + /// on-device reply is still running. Remote servers are never blocked.
228 + bool _blockIfOnDeviceBusy() {
229 + final server = ref.read(activeChatTargetProvider).server;
230 + if (server == null || !server.isOnDevice) return false;
231 + final busy = _sessions.values.any(
232 + (s) => s.isOnDevice && s.conversationId != _activeConversationId,
233 + );
234 + if (!busy) return false;
235 + state = state.copyWith(errorMessage: onDeviceBusyMessage);
236 return true;
237 }
238
239 /// Cancels the reply generating for [conversationId], if any. Used before
240 /// deleting a conversation that may be streaming in the background.
241 Future<void> cancelGenerationFor(String conversationId) async {
212 - if (_streamConversationId != conversationId) return;
213 - await _detachStreamSubscription();
214 - _uiUpdateTimer?.cancel();
215 - _uiUpdateTimer = null;
242 + final session = _sessions[conversationId];
243 + if (session == null) return;
244 + await session.detach();
245 if (!ref.mounted) return;
217 - (_streamChatService ?? ref.read(chatServiceProvider))?.cancelStream();
218 - ref.read(chatBackgroundServiceProvider).stop();
219 - _latestStreamingMessage = null;
220 - _setGlobalStreaming(false);
246 + session.chatService.cancelStream();
247 + session.latestMessage = null;
248 + _endSession(session);
249 if (_activeConversationId == conversationId) {
250 state = state.copyWith(isStreaming: false, clearStreaming: true);
251 }
252 }
253
226 - void _resetStreamMetrics() {
227 - _streamStats = null;
228 - _streamStartTime = DateTime.now();
229 - _firstTokenTime = null;
230 - }
231 -
232 - void _noteFirstToken() {
233 - _firstTokenTime ??= DateTime.now();
254 + /// Cancels every running reply (for example before wiping all chats).
255 + Future<void> cancelAllGenerations() async {
256 + for (final id in _sessions.keys.toList()) {
257 + await cancelGenerationFor(id);
258 + }
259 }
260
261 Message _finalizeStreamMessage(
262 Message msg, {
263 String? stopReason,
239 - ChatStats? stats,
240 - DateTime? startTime,
241 - DateTime? firstTokenTime,
264 + GenerationSession? session,
265 }) {
243 - final effectiveStats = stats ?? _streamStats;
244 - final effectiveStart = startTime ?? _streamStartTime;
245 - final effectiveFirstToken = firstTokenTime ?? _firstTokenTime;
266 + final effectiveStats = session?.stats;
267 + final effectiveStart = session?.startTime;
268 + final effectiveFirstToken = session?.firstTokenTime;
269 final now = DateTime.now();
270
271 int? ttftMs;
@@ -379,11 +402,13 @@ class ChatNotifier extends Notifier<ChatState> {
402 final db = ref.read(databaseProvider);
403 _saveService = MessageSaveService(db);
404 ref.onDispose(() {
382 - _uiUpdateTimer?.cancel();
383 - _invalidateStreamCallbacks();
384 - final subscription = _streamSubscription;
385 - _streamSubscription = null;
386 - subscription?.cancel();
405 + for (final session in _sessions.values) {
406 + session.cancelled = true;
407 + session.cancelUiTimer();
408 + session.subscription?.cancel();
409 + session.subscription = null;
410 + }
411 + _sessions.clear();
412 _saveService?.dispose();
413 final pending = _pendingToolApproval;
414 _pendingToolApproval = null;
@@ -459,9 +484,7 @@ class ChatNotifier extends Notifier<ChatState> {
484 if (!ref.mounted) return;
485
486 // Reattach to a reply that kept generating while this chat was closed.
462 - final liveMessage = _streamConversationId == conversation.id
463 - ? _latestStreamingMessage
464 - : null;
487 + final liveMessage = _sessions[conversation.id]?.latestMessage;
488 state = ChatState(
489 allMessages: messages,
490 messages: MessageVariants.resolveActiveTimeline(messages),
@@ -473,6 +496,7 @@ class ChatNotifier extends Notifier<ChatState> {
496 ref
497 .read(conv.activeConversationIdProvider.notifier)
498 .setActiveConversation(conversation);
499 + _publishGenerations();
500
501 if (!conversation.isTemporary) {
502 _persistLastActiveConversation(conversation.id);
@@ -598,6 +622,7 @@ class ChatNotifier extends Notifier<ChatState> {
622 ref
623 .read(conv.activeConversationIdProvider.notifier)
624 .setActiveConversation(null);
625 + _publishGenerations();
626 ref.read(chatOriginProvider.notifier).clear();
627 ref.read(messageSelectionModeProvider.notifier).disable();
628
@@ -607,47 +632,24 @@ class ChatNotifier extends Notifier<ChatState> {
632 .setEnabled(settings.mcpEnabled && settings.newChatMcpEnabled);
633 }
634
610 - void _invalidateStreamCallbacks() {
611 - _streamGeneration++;
612 - }
613 -
614 - Future<void> _detachStreamSubscription() async {
615 - _invalidateStreamCallbacks();
616 - final subscription = _streamSubscription;
617 - _streamSubscription = null;
618 - if (subscription != null) {
619 - await subscription.cancel();
620 - }
621 - }
622 -
635 + /// Cancels the open chat's reply right away (before another one starts or
636 + /// the chat is left) and marks its in-memory message cancelled. Replies
637 + /// running for other chats are never touched.
638 Future<void> _abortStreamImmediately() async {
624 - await _detachStreamSubscription();
625 - _uiUpdateTimer?.cancel();
626 - _uiUpdateTimer = null;
639 + final session = _activeSession;
640 _clearPendingApproval();
628 -
641 + if (session == null && state.streamingMessage == null) return;
642 + await session?.detach();
643 if (!ref.mounted) return;
644
631 - (_streamChatService ?? ref.read(chatServiceProvider))?.cancelStream();
632 - ref.read(chatBackgroundServiceProvider).stop();
633 - _setGlobalStreaming(false);
634 -
635 - _chunkCount = 0;
636 - _lastCheckpointTime = null;
637 - _lastSavedContentLength = 0;
638 - _lastSavedReasoningLength = 0;
645 + session?.chatService.cancelStream();
646 + if (session != null) _endSession(session);
647
648 final convId = _currentConversationId;
641 - final streamingMessage = _latestStreamingMessage ?? state.streamingMessage;
642 - _latestStreamingMessage = null;
649 + final streamingMessage = session?.latestMessage ?? state.streamingMessage;
650 + session?.latestMessage = null;
651
652 if (streamingMessage != null && convId != null) {
645 - // Capture the old stream's metrics before anything resets them for the
646 - // next generation.
647 - final stats = _streamStats;
648 - final startTime = _streamStartTime;
649 - final firstTokenTime = _firstTokenTime;
650 -
653 // Mark the in-memory copy as cancelled and inactive so it does not stay
654 // visible in the active timeline while the next stream starts.
655 final cancelled = streamingMessage.copyWith(
@@ -668,9 +670,7 @@ class ChatNotifier extends Notifier<ChatState> {
670 _persistCancelledMessageInBackground(
671 streamingMessage,
672 convId,
671 - stats: stats,
672 - startTime: startTime,
673 - firstTokenTime: firstTokenTime,
673 + session: session,
674 ),
675 );
676 }
@@ -679,9 +679,7 @@ class ChatNotifier extends Notifier<ChatState> {
679 Future<void> _persistCancelledMessageInBackground(
680 Message streamingMessage,
681 String conversationId, {
682 - ChatStats? stats,
683 - DateTime? startTime,
684 - DateTime? firstTokenTime,
682 + GenerationSession? session,
683 }) async {
684 try {
685 final finalMessage = _finalizeStreamMessage(
@@ -691,12 +689,10 @@ class ChatNotifier extends Notifier<ChatState> {
689 isProcessing: false,
690 ),
691 stopReason: 'cancelled',
694 - stats: stats,
695 - startTime: startTime,
696 - firstTokenTime: firstTokenTime,
692 + session: session,
693 );
694 await _saveService?.flush();
699 - await _saveMessage(finalMessage);
695 + await _saveMessage(finalMessage, persist: session?.persisted);
696 } catch (e) {
697 Log.error('Failed to persist cancelled stream message: $e');
698 }
@@ -724,44 +720,16 @@ class ChatNotifier extends Notifier<ChatState> {
720 .join('-');
721 }
722
727 - bool _shouldCheckpointSave(Message message) {
728 - final now = DateTime.now();
729 - final contentLength = message.content.length;
730 - final reasoningLength = message.reasoningContent?.length ?? 0;
731 -
732 - final hasNewContent =
733 - contentLength > _lastSavedContentLength ||
734 - reasoningLength > _lastSavedReasoningLength;
735 -
736 - if (!hasNewContent) return false;
737 -
738 - final meetsChunkThreshold = _chunkCount >= _checkpointChunkThreshold;
739 -
740 - final meetsTimeThreshold =
741 - _lastCheckpointTime != null &&
742 - now.difference(_lastCheckpointTime!) >= _checkpointTimeThreshold;
743 -
744 - return meetsChunkThreshold || meetsTimeThreshold;
745 - }
746 -
747 - void _resetCheckpointMetrics() {
748 - _chunkCount = 0;
749 - _lastCheckpointTime = DateTime.now();
750 - }
751 -
752 - void _updateSavedMetrics(Message message) {
753 - _lastSavedContentLength = message.content.length;
754 - _lastSavedReasoningLength = message.reasoningContent?.length ?? 0;
755 - }
756 -
723 + /// Writes the latest snapshot of every persisted running reply to disk, so
724 + /// nothing is lost if the app is killed while it is in the background.
725 Future<void> checkpointStreamingMessage({bool flush = false}) async {
758 - if (_isInMemoryChat) return;
759 - final latest = _latestStreamingMessage ?? state.streamingMessage;
760 - if (latest == null) return;
761 -
762 - _saveService?.enqueue(latest);
763 - _updateSavedMetrics(latest);
764 - _resetCheckpointMetrics();
726 + for (final session in _sessions.values) {
727 + final latest = session.latestMessage;
728 + if (!session.persisted || latest == null) continue;
729 + _saveService?.enqueue(latest);
730 + session.updateSavedMetrics(latest);
731 + session.resetCheckpointMetrics();
732 + }
733
734 if (flush) {
735 await _saveService?.flush();
@@ -843,7 +811,7 @@ class ChatNotifier extends Notifier<ChatState> {
811 }
812
813 Future<void> sendMessage(String content, {List<File>? attachments}) async {
846 - if (_blockIfGeneratingElsewhere()) return;
814 + if (_blockIfOnDeviceBusy()) return;
815 final target = ref.read(activeChatTargetProvider);
816 final server = target.server;
817 final selectedModel = target.selectedModel;
@@ -864,7 +832,11 @@ class ChatNotifier extends Notifier<ChatState> {
832 return;
833 }
834
867 - final chatService = ref.read(chatServiceProvider);
835 + final chatService = ref.read(chatServiceFactoryProvider)(server);
836 + if (chatService == null) {
837 + state = state.copyWith(errorMessage: 'No chat service available');
838 + return;
839 + }
840
841 await _abortStreamImmediately();
842 if (!ref.mounted) return;
@@ -961,10 +933,10 @@ class ChatNotifier extends Notifier<ChatState> {
933 streamingMessage: assistantMessage,
934 clearError: true,
935 );
964 - _setGlobalStreaming(true);
936 + final session = _beginSession(convId, server, chatService);
937
966 - await _saveMessage(userMessage);
967 - await _saveMessage(assistantMessage);
938 + await _saveMessage(userMessage, persist: session.persisted);
939 + await _saveMessage(assistantMessage, persist: session.persisted);
940
941 if (!ref.mounted) return;
942
@@ -988,22 +960,18 @@ class ChatNotifier extends Notifier<ChatState> {
960 );
961 }
962
991 - ref.read(chatBackgroundServiceProvider).start();
992 -
993 - _resetStreamMetrics();
994 - _resetCheckpointMetrics();
995 - _updateSavedMetrics(assistantMessage);
963 + session.resetStreamMetrics();
964 + session.resetCheckpointMetrics();
965 + session.updateSavedMetrics(assistantMessage);
966
967 final messagesForApi = _buildMessagesForApi(selectedModel);
968
969 try {
1000 - _uiUpdateTimer?.cancel();
1001 -
970 String reasoningContent = '';
971 var streamingAssistantMessage = assistantMessage;
1004 - _latestStreamingMessage = streamingAssistantMessage;
972 + session.latestMessage = streamingAssistantMessage;
973
1006 - if (chatService != null) {
974 + if (!session.cancelled) {
975 final mcpConfig = ref.read(chatMcpConfigProvider);
976 final activeIntegrations = mcpConfig.integrations
977 .where((i) => i.enabled)
@@ -1034,17 +1002,14 @@ class ChatNotifier extends Notifier<ChatState> {
1002 }
1003 }
1004
1037 - _uiUpdateTimer = Timer.periodic(const Duration(milliseconds: 80), (_) {
1038 - updateUiState();
1039 - });
1040 -
1041 - final streamGeneration = _streamGeneration;
1042 - final streamInMemory = _isInMemoryChat;
1043 - _persistedStreamConversationId = streamInMemory
1044 - ? null
1045 - : assistantMessage.conversationId;
1046 - _streamChatService = chatService;
1047 - _streamSubscription = chatService
1005 + session.uiUpdateTimer = Timer.periodic(
1006 + const Duration(milliseconds: 80),
1007 + (_) {
1008 + updateUiState();
1009 + },
1010 + );
1011 +
1012 + session.subscription = chatService
1013 .sendMessage(
1014 server: server,
1015 modelId: effectiveModelId,
@@ -1055,7 +1020,7 @@ class ChatNotifier extends Notifier<ChatState> {
1020 )
1021 .listen(
1022 (response) async {
1058 - if (streamGeneration != _streamGeneration || !ref.mounted) {
1023 + if (session.cancelled || !ref.mounted) {
1024 return;
1025 }
1026 final streamConvId = assistantMessage.conversationId;
@@ -1064,7 +1029,7 @@ class ChatNotifier extends Notifier<ChatState> {
1029 switch (response.type) {
1030 case ChatResponseType.message:
1031 if (response.content?.isNotEmpty ?? false) {
1067 - _noteFirstToken();
1032 + session.noteFirstToken();
1033 }
1034 streamingAssistantMessage = streamingAssistantMessage
1035 .copyWith(
@@ -1073,22 +1038,24 @@ class ChatNotifier extends Notifier<ChatState> {
1038 (response.content ?? ''),
1039 isProcessing: false,
1040 );
1076 - _latestStreamingMessage = streamingAssistantMessage;
1041 + session.latestMessage = streamingAssistantMessage;
1042
1078 - _chunkCount++;
1043 + session.chunkCount++;
1044
1080 - if (!streamInMemory &&
1081 - _shouldCheckpointSave(streamingAssistantMessage)) {
1045 + if (session.persisted &&
1046 + session.shouldCheckpointSave(
1047 + streamingAssistantMessage,
1048 + )) {
1049 _saveService?.enqueue(streamingAssistantMessage);
1083 - _updateSavedMetrics(streamingAssistantMessage);
1084 - _resetCheckpointMetrics();
1050 + session.updateSavedMetrics(streamingAssistantMessage);
1051 + session.resetCheckpointMetrics();
1052 }
1053
1054 stateNeedsUpdate = true;
1055 break;
1056 case ChatResponseType.reasoning:
1057 if (response.reasoningContent?.isNotEmpty ?? false) {
1091 - _noteFirstToken();
1058 + session.noteFirstToken();
1059 }
1060 reasoningContent += response.reasoningContent ?? '';
1061 streamingAssistantMessage = streamingAssistantMessage
@@ -1096,15 +1063,17 @@ class ChatNotifier extends Notifier<ChatState> {
1063 reasoningContent: reasoningContent,
1064 isProcessing: false,
1065 );
1099 - _latestStreamingMessage = streamingAssistantMessage;
1066 + session.latestMessage = streamingAssistantMessage;
1067
1101 - _chunkCount++;
1068 + session.chunkCount++;
1069
1103 - if (!streamInMemory &&
1104 - _shouldCheckpointSave(streamingAssistantMessage)) {
1070 + if (session.persisted &&
1071 + session.shouldCheckpointSave(
1072 + streamingAssistantMessage,
1073 + )) {
1074 _saveService?.enqueue(streamingAssistantMessage);
1106 - _updateSavedMetrics(streamingAssistantMessage);
1107 - _resetCheckpointMetrics();
1075 + session.updateSavedMetrics(streamingAssistantMessage);
1076 + session.resetCheckpointMetrics();
1077 }
1078
1079 stateNeedsUpdate = true;
@@ -1114,7 +1083,7 @@ class ChatNotifier extends Notifier<ChatState> {
1083 streamingAssistantMessage.isProcessing;
1084 streamingAssistantMessage = streamingAssistantMessage
1085 .copyWith(isProcessing: true);
1117 - _latestStreamingMessage = streamingAssistantMessage;
1086 + session.latestMessage = streamingAssistantMessage;
1087 stateNeedsUpdate = true;
1088 if (!wasProcessing) {
1089 ref.read(appHapticsProvider).light();
@@ -1123,8 +1092,7 @@ class ChatNotifier extends Notifier<ChatState> {
1092 case ChatResponseType.timeoutError:
1093 case ChatResponseType.error:
1094 streamHadError = true;
1126 - _uiUpdateTimer?.cancel();
1127 - _uiUpdateTimer = null;
1095 + session.cancelUiTimer();
1096 streamingAssistantMessage = streamingAssistantMessage.copyWith(
1097 status: MessageStatus.error,
1098 errorMessage:
@@ -1134,7 +1102,7 @@ class ChatNotifier extends Notifier<ChatState> {
1102 : 'An unknown error occurred.'),
1103 isProcessing: false,
1104 );
1137 - _latestStreamingMessage = streamingAssistantMessage;
1105 + session.latestMessage = streamingAssistantMessage;
1106
1107 if (isCurrentContext) {
1108 _replaceMessageInState(
@@ -1146,10 +1114,13 @@ class ChatNotifier extends Notifier<ChatState> {
1114 errorMessage: streamingAssistantMessage.errorMessage,
1115 );
1116 }
1149 - _setGlobalStreaming(false);
1150 - _latestStreamingMessage = null;
1117 + _endSession(session);
1118 + session.latestMessage = null;
1119 await _saveService?.flush();
1152 - await _saveMessage(streamingAssistantMessage);
1120 + await _saveMessage(
1121 + streamingAssistantMessage,
1122 + persist: session.persisted,
1123 + );
1124 break;
1125 case ChatResponseType.toolCall:
1126 if (response.toolCall != null) {
@@ -1159,35 +1130,28 @@ class ChatNotifier extends Notifier<ChatState> {
1130 case ChatResponseType.invalidToolCall:
1131 case ChatResponseType.done:
1132 if (response.stats != null) {
1162 - _streamStats = response.stats;
1133 + session.stats = response.stats;
1134 }
1135 break;
1136 }
1137 },
1138 onDone: () async {
1168 - _uiUpdateTimer?.cancel();
1169 - _uiUpdateTimer = null;
1139 + session.cancelUiTimer();
1140 updateUiState();
1141
1142 if (streamHadError) {
1143 // The error branch already finalised the message and
1144 // stopped streaming — do not overwrite the error state
1145 // with success/default text.
1176 - if (!streamInMemory) {
1146 + if (session.persisted) {
1147 await _saveService?.flush();
1148 }
1179 - if (ref.mounted) {
1180 - ref.read(chatBackgroundServiceProvider).stop();
1181 - }
1149 return;
1150 }
1151
1185 - if (!streamInMemory) {
1152 + if (session.persisted) {
1153 await _saveService?.flush();
1154 }
1188 - if (ref.mounted) {
1189 - ref.read(chatBackgroundServiceProvider).stop();
1190 - }
1155 final streamConvId = assistantMessage.conversationId;
1156 final isCurrentContext = _activeConversationId == streamConvId;
1157 final streamingMessage = streamingAssistantMessage;
@@ -1203,7 +1167,7 @@ class ChatNotifier extends Notifier<ChatState> {
1167 'Model failed to respond. This may happen with free tier models that refuse certain prompts or when the service is busy.',
1168 isProcessing: false,
1169 );
1206 - await _saveMessage(errorMessage);
1170 + await _saveMessage(errorMessage, persist: session.persisted);
1171 if (ref.mounted && isCurrentContext) {
1172 _replaceMessageInState(errorMessage, clearStreaming: true);
1173 state = state.copyWith(
@@ -1212,9 +1176,9 @@ class ChatNotifier extends Notifier<ChatState> {
1176 );
1177 }
1178 if (ref.mounted) {
1215 - _setGlobalStreaming(false);
1179 + _endSession(session);
1180 }
1217 - _latestStreamingMessage = null;
1181 + session.latestMessage = null;
1182 } else {
1183 var finalMessage = _finalizeStreamMessage(
1184 streamingMessage.copyWith(
@@ -1222,6 +1186,7 @@ class ChatNotifier extends Notifier<ChatState> {
1186 isProcessing: false,
1187 ),
1188 stopReason: 'complete',
1189 + session: session,
1190 );
1191
1192 if (mcpConfig.enabled && collectedToolCalls.isNotEmpty) {
@@ -1358,7 +1323,10 @@ class ChatNotifier extends Notifier<ChatState> {
1323 // post-stream bookkeeping that was meant for the
1324 // first turn only.
1325 if (ref.mounted) {
1361 - await _saveMessage(finalMessage);
1326 + await _saveMessage(
1327 + finalMessage,
1328 + persist: session.persisted,
1329 + );
1330 if (isCurrentContext) {
1331 _replaceMessageInState(
1332 finalMessage,
@@ -1370,19 +1338,26 @@ class ChatNotifier extends Notifier<ChatState> {
1338 }
1339 } catch (e) {
1340 Log.error('Tool execution loop failed: $e');
1341 + // A follow-up that failed after registering its own
1342 + // session must not leave it running forever.
1343 + final followUp = _sessions[session.conversationId];
1344 + if (followUp != null && !identical(followUp, session)) {
1345 + await followUp.detach();
1346 + _endSession(followUp);
1347 + }
1348 }
1349 }
1350
1376 - await _saveMessage(finalMessage);
1351 + await _saveMessage(finalMessage, persist: session.persisted);
1352 if (ref.mounted && isCurrentContext) {
1353 _replaceMessageInState(finalMessage, clearStreaming: true);
1354 state = state.copyWith(isStreaming: false);
1355 _maybeAutoGenerateTitleAfterFirstReply();
1356 }
1357 if (ref.mounted) {
1383 - _setGlobalStreaming(false);
1358 + _endSession(session);
1359 }
1385 - _latestStreamingMessage = null;
1360 + session.latestMessage = null;
1361
1362 await _syncConversationStatsAfterGeneration(
1363 streamConvId,
@@ -1398,11 +1373,6 @@ class ChatNotifier extends Notifier<ChatState> {
1373 );
1374 }
1375
1401 - _chunkCount = 0;
1402 - _lastCheckpointTime = null;
1403 - _lastSavedContentLength = 0;
1404 - _lastSavedReasoningLength = 0;
1405 -
1376 // Auto-speak: read the response aloud if the setting
1377 // is enabled and voice mode is not already handling TTS.
1378 if (ref.mounted) {
@@ -1430,51 +1400,37 @@ class ChatNotifier extends Notifier<ChatState> {
1400 }
1401 },
1402 onError: (error) async {
1433 - _uiUpdateTimer?.cancel();
1434 - _uiUpdateTimer = null;
1435 - _chunkCount = 0;
1436 - _lastCheckpointTime = null;
1437 - _lastSavedContentLength = 0;
1438 - _lastSavedReasoningLength = 0;
1403 + session.cancelUiTimer();
1404 await _saveService?.flush();
1440 - final errorMessage =
1441 - (_latestStreamingMessage ?? state.streamingMessage)
1442 - ?.copyWith(
1443 - status: MessageStatus.error,
1444 - errorMessage: error.toString(),
1445 - );
1446 - if (errorMessage != null) {
1447 - await _saveMessage(errorMessage);
1448 -
1449 - if (ref.mounted) {
1450 - ref.read(chatBackgroundServiceProvider).stop();
1451 - }
1405 + final errorMessage = streamingAssistantMessage.copyWith(
1406 + status: MessageStatus.error,
1407 + errorMessage: error.toString(),
1408 + );
1409 + await _saveMessage(errorMessage, persist: session.persisted);
1410
1453 - final streamConvId = assistantMessage.conversationId;
1454 - if (ref.mounted && _currentConversationId == streamConvId) {
1455 - _replaceMessageInState(errorMessage, clearStreaming: true);
1456 - state = state.copyWith(
1457 - isStreaming: false,
1458 - errorMessage: error.toString(),
1459 - );
1460 - }
1411 + final streamConvId = assistantMessage.conversationId;
1412 + if (ref.mounted && _currentConversationId == streamConvId) {
1413 + _replaceMessageInState(errorMessage, clearStreaming: true);
1414 + state = state.copyWith(
1415 + isStreaming: false,
1416 + errorMessage: error.toString(),
1417 + );
1418 }
1419 if (ref.mounted) {
1463 - _setGlobalStreaming(false);
1420 + _endSession(session);
1421 }
1465 - _latestStreamingMessage = null;
1422 + session.latestMessage = null;
1423 },
1424 );
1425 }
1426 } catch (e) {
1470 - _uiUpdateTimer?.cancel();
1471 - _uiUpdateTimer = null;
1427 + session.cancelUiTimer();
1428 final errorMsg = assistantMessage.copyWith(
1429 status: MessageStatus.error,
1430 errorMessage: e.toString(),
1431 isProcessing: false,
1432 );
1477 - await _saveMessage(errorMsg);
1433 + await _saveMessage(errorMsg, persist: session.persisted);
1434
1435 if (!ref.mounted) return;
1436
@@ -1483,9 +1439,8 @@ class ChatNotifier extends Notifier<ChatState> {
1439 errorMessage: e.toString(),
1440 clearStreaming: true,
1441 );
1486 - _setGlobalStreaming(false);
1487 - _latestStreamingMessage = null;
1488 - ref.read(chatBackgroundServiceProvider).stop();
1442 + _endSession(session);
1443 + session.latestMessage = null;
1444 }
1445 }
1446
@@ -1950,24 +1905,17 @@ class ChatNotifier extends Notifier<ChatState> {
1905 }
1906
1907 Future<void> cancelStream() async {
1953 - await _detachStreamSubscription();
1954 - _uiUpdateTimer?.cancel();
1955 - _uiUpdateTimer = null;
1908 + final session = _activeSession;
1909 _clearPendingApproval();
1910 + await session?.detach();
1911 if (!ref.mounted) return;
1912
1959 - (_streamChatService ?? ref.read(chatServiceProvider))?.cancelStream();
1960 - ref.read(chatBackgroundServiceProvider).stop();
1961 -
1962 - _chunkCount = 0;
1963 - _lastCheckpointTime = null;
1964 - _lastSavedContentLength = 0;
1965 - _lastSavedReasoningLength = 0;
1913 + session?.chatService.cancelStream();
1914
1915 await _saveService?.flush();
1916 if (!ref.mounted) return;
1917
1970 - final streamingMessage = _latestStreamingMessage ?? state.streamingMessage;
1918 + final streamingMessage = session?.latestMessage ?? state.streamingMessage;
1919 if (streamingMessage != null) {
1920 final finalMessage = _finalizeStreamMessage(
1921 streamingMessage.copyWith(
@@ -1975,20 +1923,21 @@ class ChatNotifier extends Notifier<ChatState> {
1923 isProcessing: false,
1924 ),
1925 stopReason: 'cancelled',
1926 + session: session,
1927 );
1979 - await _saveMessage(finalMessage);
1928 + await _saveMessage(finalMessage, persist: session?.persisted);
1929 if (!ref.mounted) return;
1930 _replaceMessageInState(finalMessage, clearStreaming: true);
1931 }
1932
1933 if (!ref.mounted) return;
1934 state = state.copyWith(isStreaming: false, clearStreaming: true);
1986 - _setGlobalStreaming(false);
1987 - _latestStreamingMessage = null;
1935 + session?.latestMessage = null;
1936 + if (session != null) _endSession(session);
1937 }
1938
1939 Future<void> retryLastMessage() async {
1991 - if (_blockIfGeneratingElsewhere()) return;
1940 + if (_blockIfOnDeviceBusy()) return;
1941 final messages = state.messages;
1942 if (messages.isEmpty) return;
1943
@@ -2037,7 +1986,7 @@ class ChatNotifier extends Notifier<ChatState> {
1986 }
1987
1988 Future<void> continueFromMessage(String messageId) async {
2040 - if (_blockIfGeneratingElsewhere()) return;
1989 + if (_blockIfOnDeviceBusy()) return;
1990 final messages = state.messages;
1991 if (messages.isEmpty || messages.last.id != messageId) return;
1992 final assistant = messages.last;
@@ -2045,8 +1994,11 @@ class ChatNotifier extends Notifier<ChatState> {
1994 if (state.isStreaming) return;
1995
1996 final target = ref.read(activeChatTargetProvider);
1997 + final server = target.server;
1998 final effectiveModelId = target.effectiveModelId;
2049 - if (target.server == null || effectiveModelId == null) return;
1999 + if (server == null || effectiveModelId == null) return;
2000 + final chatService = ref.read(chatServiceFactoryProvider)(server);
2001 + if (chatService == null) return;
2002
2003 final streamingAssistant = assistant.copyWith(
2004 status: MessageStatus.streaming,
@@ -2062,13 +2014,17 @@ class ChatNotifier extends Notifier<ChatState> {
2014 streamingMessage: streamingAssistant,
2015 clearError: true,
2016 );
2065 - _setGlobalStreaming(true);
2066 - ref.read(chatBackgroundServiceProvider).start();
2067 - _resetCheckpointMetrics();
2068 - _updateSavedMetrics(streamingAssistant);
2069 - _latestStreamingMessage = streamingAssistant;
2017 + final session = _beginSession(
2018 + streamingAssistant.conversationId,
2019 + server,
2020 + chatService,
2021 + );
2022 + session.resetCheckpointMetrics();
2023 + session.updateSavedMetrics(streamingAssistant);
2024 + session.latestMessage = streamingAssistant;
2025
2026 await _runAssistantStream(
2027 + session,
2028 streamingAssistant,
2029 target.selectedModel,
2030 effectiveModelId: effectiveModelId,
@@ -2121,7 +2077,7 @@ class ChatNotifier extends Notifier<ChatState> {
2077 }
2078
2079 Future<void> retryMessage(String messageId) async {
2124 - if (_blockIfGeneratingElsewhere()) return;
2080 + if (_blockIfOnDeviceBusy()) return;
2081 if (state.isStreaming || _isRegenerating) return;
2082 _isRegenerating = true;
2083 try {
@@ -2165,7 +2121,7 @@ class ChatNotifier extends Notifier<ChatState> {
2121 required int threadOrder,
2122 required int variantIndex,
2123 }) async {
2168 - if (_blockIfGeneratingElsewhere()) return;
2124 + if (_blockIfOnDeviceBusy()) return;
2125 final target = ref.read(activeChatTargetProvider);
2126 final selectedModel = target.selectedModel;
2127 final server = target.server;
@@ -2202,14 +2158,27 @@ class ChatNotifier extends Notifier<ChatState> {
2158 streamingMessage: assistantMessage,
2159 clearError: true,
2160 );
2205 - _setGlobalStreaming(true);
2206 - await _saveMessage(assistantMessage);
2161 + final chatService = ref.read(chatServiceFactoryProvider)(server);
2162 + if (chatService == null) {
2163 + state = state.copyWith(
2164 + isStreaming: false,
2165 + errorMessage: 'No chat service available',
2166 + clearStreaming: true,
2167 + );
2168 + return;
2169 + }
2170 + final session = _beginSession(
2171 + assistantMessage.conversationId,
2172 + server,
2173 + chatService,
2174 + );
2175 + await _saveMessage(assistantMessage, persist: session.persisted);
2176
2208 - ref.read(chatBackgroundServiceProvider).start();
2209 - _resetCheckpointMetrics();
2210 - _updateSavedMetrics(assistantMessage);
2177 + session.resetCheckpointMetrics();
2178 + session.updateSavedMetrics(assistantMessage);
2179
2180 await _runAssistantStream(
2181 + session,
2182 assistantMessage,
2183 selectedModel,
2184 effectiveModelId: effectiveModelId,
@@ -2339,9 +2308,12 @@ class ChatNotifier extends Notifier<ChatState> {
2308 streamingMessage: continuationMessage,
2309 clearStreaming: false,
2310 );
2342 - _setGlobalStreaming(true);
2343 - ref.read(chatBackgroundServiceProvider).start();
2344 - await _saveMessage(continuationMessage);
2311 + final session = _beginSession(
2312 + continuationMessage.conversationId,
2313 + server,
2314 + chatService,
2315 + );
2316 + await _saveMessage(continuationMessage, persist: session.persisted);
2317 if (!ref.mounted) return;
2318
2319 // Reuse the streaming logic — `_runAssistantStream` builds the API
@@ -2351,6 +2323,7 @@ class ChatNotifier extends Notifier<ChatState> {
2323 // yields a final answer or `maxIterations` (enforced inside
2324 // `ToolExecutionLoop`) is reached.
2325 await _runAssistantStream(
2326 + session,
2327 continuationMessage,
2328 selectedModel,
2329 effectiveModelId: effectiveModelId,
@@ -2358,40 +2331,26 @@ class ChatNotifier extends Notifier<ChatState> {
2331 }
2332
2333 Future<void> _runAssistantStream(
2334 + GenerationSession session,
2335 Message assistantMessage,
2336 ModelInfo? selectedModel, {
2337 required String effectiveModelId,
2338 bool continueGeneration = false,
2339 }) async {
2366 - final server = ref.read(activeServerProvider);
2340 + final server = session.server;
2341 + final chatService = session.chatService;
2342 final chatParams = ref.read(chatParamsProvider);
2368 - final chatService = ref.read(chatServiceProvider);
2369 - if (server == null || chatService == null) {
2370 - final errorMessage = assistantMessage.copyWith(
2371 - status: MessageStatus.error,
2372 - errorMessage: 'No chat service available',
2373 - isProcessing: false,
2374 - );
2375 - await _saveMessage(errorMessage);
2376 - _replaceMessageInAll(errorMessage, clearStreaming: true);
2377 - state = state.copyWith(isStreaming: false, clearStreaming: true);
2378 - _setGlobalStreaming(false);
2379 - ref.read(chatBackgroundServiceProvider).stop();
2380 - return;
2381 - }
2343
2344 final messagesForApi = continueGeneration
2345 ? _buildMessagesForContinue(selectedModel, assistantMessage)
2346 : _buildMessagesForApi(selectedModel);
2347
2348 try {
2388 - await _detachStreamSubscription();
2389 - _uiUpdateTimer?.cancel();
2390 - _resetStreamMetrics();
2349 + session.resetStreamMetrics();
2350
2351 String reasoningContent = '';
2352 var streamingAssistantMessage = assistantMessage;
2394 - _latestStreamingMessage = streamingAssistantMessage;
2353 + session.latestMessage = streamingAssistantMessage;
2354 var isFirstContinueChunk = continueGeneration;
2355
2356 final mcpConfig = ref.read(chatMcpConfigProvider);
@@ -2418,17 +2377,15 @@ class ChatNotifier extends Notifier<ChatState> {
2377 }
2378 }
2379
2421 - _uiUpdateTimer = Timer.periodic(const Duration(milliseconds: 80), (_) {
2380 + if (session.cancelled) return;
2381 +
2382 + session.uiUpdateTimer = Timer.periodic(const Duration(milliseconds: 80), (
2383 + _,
2384 + ) {
2385 updateUiState();
2386 });
2387
2425 - final streamGeneration = _streamGeneration;
2426 - final streamInMemory = _isInMemoryChat;
2427 - _persistedStreamConversationId = streamInMemory
2428 - ? null
2429 - : assistantMessage.conversationId;
2430 - _streamChatService = chatService;
2431 - _streamSubscription = chatService
2388 + session.subscription = chatService
2389 .sendMessage(
2390 server: server,
2391 modelId: effectiveModelId,
@@ -2440,14 +2397,14 @@ class ChatNotifier extends Notifier<ChatState> {
2397 )
2398 .listen(
2399 (response) async {
2443 - if (streamGeneration != _streamGeneration) return;
2400 + if (session.cancelled || !ref.mounted) return;
2401 final streamConvId = assistantMessage.conversationId;
2402 final isCurrentContext = _activeConversationId == streamConvId;
2403
2404 switch (response.type) {
2405 case ChatResponseType.message:
2406 if (response.content?.isNotEmpty ?? false) {
2450 - _noteFirstToken();
2407 + session.noteFirstToken();
2408 }
2409 var delta = response.content ?? '';
2410 if (isFirstContinueChunk && delta.isNotEmpty) {
@@ -2467,19 +2424,19 @@ class ChatNotifier extends Notifier<ChatState> {
2424 content: streamingAssistantMessage.content + delta,
2425 isProcessing: false,
2426 );
2470 - _latestStreamingMessage = streamingAssistantMessage;
2471 - _chunkCount++;
2472 - if (!streamInMemory &&
2473 - _shouldCheckpointSave(streamingAssistantMessage)) {
2427 + session.latestMessage = streamingAssistantMessage;
2428 + session.chunkCount++;
2429 + if (session.persisted &&
2430 + session.shouldCheckpointSave(streamingAssistantMessage)) {
2431 _saveService?.enqueue(streamingAssistantMessage);
2475 - _updateSavedMetrics(streamingAssistantMessage);
2476 - _resetCheckpointMetrics();
2432 + session.updateSavedMetrics(streamingAssistantMessage);
2433 + session.resetCheckpointMetrics();
2434 }
2435 stateNeedsUpdate = true;
2436 break;
2437 case ChatResponseType.reasoning:
2438 if (response.reasoningContent?.isNotEmpty ?? false) {
2482 - _noteFirstToken();
2439 + session.noteFirstToken();
2440 }
2441 reasoningContent += response.reasoningContent ?? '';
2442 streamingAssistantMessage = streamingAssistantMessage
@@ -2487,27 +2444,26 @@ class ChatNotifier extends Notifier<ChatState> {
2444 reasoningContent: reasoningContent,
2445 isProcessing: false,
2446 );
2490 - _latestStreamingMessage = streamingAssistantMessage;
2491 - _chunkCount++;
2492 - if (!streamInMemory &&
2493 - _shouldCheckpointSave(streamingAssistantMessage)) {
2447 + session.latestMessage = streamingAssistantMessage;
2448 + session.chunkCount++;
2449 + if (session.persisted &&
2450 + session.shouldCheckpointSave(streamingAssistantMessage)) {
2451 _saveService?.enqueue(streamingAssistantMessage);
2495 - _updateSavedMetrics(streamingAssistantMessage);
2496 - _resetCheckpointMetrics();
2452 + session.updateSavedMetrics(streamingAssistantMessage);
2453 + session.resetCheckpointMetrics();
2454 }
2455 stateNeedsUpdate = true;
2456 break;
2457 case ChatResponseType.processing:
2458 streamingAssistantMessage = streamingAssistantMessage
2459 .copyWith(isProcessing: true);
2503 - _latestStreamingMessage = streamingAssistantMessage;
2460 + session.latestMessage = streamingAssistantMessage;
2461 stateNeedsUpdate = true;
2462 break;
2463 case ChatResponseType.timeoutError:
2464 case ChatResponseType.error:
2465 streamHadError = true;
2509 - _uiUpdateTimer?.cancel();
2510 - _uiUpdateTimer = null;
2466 + session.cancelUiTimer();
2467 streamingAssistantMessage = _finalizeStreamMessage(
2468 streamingAssistantMessage.copyWith(
2469 status: MessageStatus.error,
@@ -2515,8 +2471,12 @@ class ChatNotifier extends Notifier<ChatState> {
2471 isProcessing: false,
2472 ),
2473 stopReason: 'error',
2474 + session: session,
2475 + );
2476 + await _saveMessage(
2477 + streamingAssistantMessage,
2478 + persist: session.persisted,
2479 );
2519 - await _saveMessage(streamingAssistantMessage);
2480 if (ref.mounted) {
2481 if (isCurrentContext) {
2482 _replaceMessageInAll(
@@ -2528,13 +2488,12 @@ class ChatNotifier extends Notifier<ChatState> {
2488 clearStreaming: true,
2489 );
2490 }
2531 - _setGlobalStreaming(false);
2532 - ref.read(chatBackgroundServiceProvider).stop();
2491 + _endSession(session);
2492 }
2493 break;
2494 case ChatResponseType.done:
2495 if (response.stats != null) {
2537 - _streamStats = response.stats;
2496 + session.stats = response.stats;
2497 }
2498 break;
2499 default:
@@ -2542,8 +2501,7 @@ class ChatNotifier extends Notifier<ChatState> {
2501 }
2502 },
2503 onDone: () async {
2545 - _uiUpdateTimer?.cancel();
2546 - _uiUpdateTimer = null;
2504 + session.cancelUiTimer();
2505 if (streamHadError) {
2506 // The error branch already finalised the message and
2507 // stopped streaming — do not overwrite the error state
@@ -2559,8 +2517,9 @@ class ChatNotifier extends Notifier<ChatState> {
2517 isProcessing: false,
2518 ),
2519 stopReason: 'complete',
2520 + session: session,
2521 );
2563 - await _saveMessage(finalMessage);
2522 + await _saveMessage(finalMessage, persist: session.persisted);
2523 if (!ref.mounted) return;
2524 if (isCurrentContext) {
2525 _replaceMessageInAll(finalMessage, clearStreaming: true);
@@ -2571,8 +2530,7 @@ class ChatNotifier extends Notifier<ChatState> {
2530 _maybeAutoGenerateTitleAfterFirstReply();
2531 }
2532 if (ref.mounted) {
2574 - _setGlobalStreaming(false);
2575 - ref.read(chatBackgroundServiceProvider).stop();
2533 + _endSession(session);
2534 }
2535 await _syncConversationStatsAfterGeneration(
2536 streamConvId,
@@ -2609,8 +2567,7 @@ class ChatNotifier extends Notifier<ChatState> {
2567 },
2568 onError: (Object error, StackTrace stackTrace) async {
2569 Log.error('Stream error: $error');
2612 - _uiUpdateTimer?.cancel();
2613 - _uiUpdateTimer = null;
2570 + session.cancelUiTimer();
2571 final isCurrentContext =
2572 _activeConversationId == assistantMessage.conversationId;
2573 final errorMessage = streamingAssistantMessage.copyWith(
@@ -2618,7 +2575,7 @@ class ChatNotifier extends Notifier<ChatState> {
2575 errorMessage: error.toString(),
2576 isProcessing: false,
2577 );
2621 - await _saveMessage(errorMessage);
2578 + await _saveMessage(errorMessage, persist: session.persisted);
2579 if (!ref.mounted) return;
2580 if (isCurrentContext) {
2581 _replaceMessageInAll(errorMessage, clearStreaming: true);
@@ -2629,8 +2586,7 @@ class ChatNotifier extends Notifier<ChatState> {
2586 );
2587 }
2588 if (ref.mounted) {
2632 - _setGlobalStreaming(false);
2633 - ref.read(chatBackgroundServiceProvider).stop();
2589 + _endSession(session);
2590 }
2591 },
2592 );
@@ -2641,7 +2597,7 @@ class ChatNotifier extends Notifier<ChatState> {
2597 errorMessage: e.toString(),
2598 clearStreaming: true,
2599 );
2644 - _setGlobalStreaming(false);
2600 + _endSession(session);
2601 }
2602 }
2603
@@ -2786,7 +2742,7 @@ class ChatNotifier extends Notifier<ChatState> {
2742 }
2743
2744 Future<void> generateResponseForLastUser() async {
2789 - if (_blockIfGeneratingElsewhere()) return;
2745 + if (_blockIfOnDeviceBusy()) return;
2746 if (state.isStreaming || _isRegenerating) return;
2747 _isRegenerating = true;
2748 try {
@@ -2806,7 +2762,7 @@ class ChatNotifier extends Notifier<ChatState> {
2762 }
2763
2764 Future<void> generateAiUserMessage() async {
2809 - if (_blockIfGeneratingElsewhere()) return;
2765 + if (_blockIfOnDeviceBusy()) return;
2766 if (state.isStreaming) return;
2767 if (state.messages.isEmpty) return;
2768
@@ -2887,7 +2843,7 @@ class ChatNotifier extends Notifier<ChatState> {
2843 }
2844
2845 Future<void> editMessage(String messageId, String newContent) async {
2890 - if (_blockIfGeneratingElsewhere()) return;
2846 + if (_blockIfOnDeviceBusy()) return;
2847 final messageIndex = state.messages.indexWhere((m) => m.id == messageId);
2848 if (messageIndex == -1) return;
2849
@@ -2928,17 +2884,16 @@ class ChatNotifier extends Notifier<ChatState> {
2884 );
2885 }
2886
2931 - Future<void> _saveMessage(Message message) async {
2887 + /// Saves [message] unless it belongs to an in-memory (temporary) chat.
2888 + /// A reply's own saves pass [persist] from its [GenerationSession], because
2889 + /// the open chat may no longer be the chat the reply belongs to.
2890 + Future<void> _saveMessage(Message message, {bool? persist}) async {
2891 // Check `ref.mounted` before touching `state` (via `_isInMemoryChat`).
2892 // On a disposed notifier, accessing `state` throws "Cannot use the Ref of
2893 // NotifierProvider ... after it has been disposed" — see issue #73.
2894 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 - }
2895 + if (persist == false) return;
2896 + if (persist == null && _isInMemoryChat) return;
2897 await persistMessage(message);
2898 }
2899
lib/features/chat/providers/chat_providers.dart
+1
@@ -3,6 +3,7 @@ export 'chat_origin_provider.dart';
3 export 'chat_params_providers.dart';
4 export 'chat_reasoning_providers.dart';
5 export 'chat_service_providers.dart';
6 +export 'generation_session.dart' show ActiveGeneration;
7 export 'message_selection_provider.dart';
8 export 'model_loading_providers.dart';
9 export 'model_selection_providers.dart';
lib/features/chat/providers/chat_service_providers.dart
+21
@@ -50,6 +50,27 @@ final chatServiceProvider = Provider<ChatService?>((ref) {
50 return service;
51 });
52
53 +/// Builds the service that owns one reply. Remote services keep a single
54 +/// cancel token, so every concurrent reply gets its own instance; on-device
55 +/// replies share the one engine-backed [chatServiceProvider] service.
56 +final chatServiceFactoryProvider = Provider<ChatService? Function(Server)>((
57 + ref,
58 +) {
59 + return (server) {
60 + if (server.isOnDevice) return ref.read(chatServiceProvider);
61 + final settings = ref.read(settingsProvider);
62 + return createChatServiceForServer(
63 + server: server,
64 + dio: ref.read(dioProvider),
65 + onDeviceGemmaService: ref.read(onDeviceGemmaServiceProvider),
66 + onDeviceLlamaService: ref.read(onDeviceLlamaServiceProvider),
67 + onDeviceMlxService: ref.read(onDeviceMlxServiceProvider),
68 + imageCompressionEnabled: settings.imageCompressionEnabled,
69 + imageCompressionLevel: settings.imageCompressionLevel,
70 + );
71 + };
72 +});
73 +
74 ChatService createChatServiceForServer({
75 required Server server,
76 required Dio dio,
lib/features/chat/providers/generation_session.dart new
+121
@@ -0,0 +1,121 @@
1 +import 'dart:async';
2 +
3 +import 'package:localmind/features/servers/data/models/server.dart';
4 +
5 +import '../data/chat_service.dart';
6 +import '../data/models/message.dart' show Message;
7 +
8 +/// Snapshot of a reply that is generating, exposed to the UI. Only the
9 +/// conversation and whether it occupies the (single) on-device engine matter
10 +/// to views.
11 +class ActiveGeneration {
12 + const ActiveGeneration({
13 + required this.conversationId,
14 + required this.isOnDevice,
15 + });
16 +
17 + final String conversationId;
18 + final bool isOnDevice;
19 +}
20 +
21 +/// Everything that belongs to one in-flight reply. Remote replies (LM Studio,
22 +/// Ollama, OpenRouter, ...) can run one session per conversation at the same
23 +/// time; on-device replies share a single engine, so at most one on-device
24 +/// session exists at a time.
25 +class GenerationSession {
26 + GenerationSession({
27 + required this.conversationId,
28 + required this.server,
29 + required this.chatService,
30 + required this.persisted,
31 + });
32 +
33 + static const _checkpointChunkThreshold = 20;
34 + static const _checkpointTimeThreshold = Duration(seconds: 2);
35 +
36 + final String conversationId;
37 + final Server server;
38 +
39 + /// Service that owns this stream. Cancelling must target it, never a
40 + /// service shared with another reply.
41 + final ChatService chatService;
42 +
43 + /// False for temporary chats, whose messages are never written to disk.
44 + final bool persisted;
45 +
46 + bool get isOnDevice => server.isOnDevice;
47 +
48 + StreamSubscription<ChatResponse>? subscription;
49 + Timer? uiUpdateTimer;
50 +
51 + /// Most recent snapshot of the streaming assistant message. A chat that is
52 + /// reopened while this reply still runs reattaches to it.
53 + Message? latestMessage;
54 +
55 + /// Set once the reply is cancelled or replaced; late events are ignored.
56 + bool cancelled = false;
57 +
58 + int chunkCount = 0;
59 + DateTime? lastCheckpointTime;
60 + int lastSavedContentLength = 0;
61 + int lastSavedReasoningLength = 0;
62 +
63 + ChatStats? stats;
64 + DateTime? startTime;
65 + DateTime? firstTokenTime;
66 +
67 + void resetStreamMetrics() {
68 + stats = null;
69 + startTime = DateTime.now();
70 + firstTokenTime = null;
71 + }
72 +
73 + void noteFirstToken() {
74 + firstTokenTime ??= DateTime.now();
75 + }
76 +
77 + bool shouldCheckpointSave(Message message) {
78 + final now = DateTime.now();
79 + final contentLength = message.content.length;
80 + final reasoningLength = message.reasoningContent?.length ?? 0;
81 +
82 + final hasNewContent =
83 + contentLength > lastSavedContentLength ||
84 + reasoningLength > lastSavedReasoningLength;
85 + if (!hasNewContent) return false;
86 +
87 + final meetsChunkThreshold = chunkCount >= _checkpointChunkThreshold;
88 + final meetsTimeThreshold =
89 + lastCheckpointTime != null &&
90 + now.difference(lastCheckpointTime!) >= _checkpointTimeThreshold;
91 +
92 + return meetsChunkThreshold || meetsTimeThreshold;
93 + }
94 +
95 + void resetCheckpointMetrics() {
96 + chunkCount = 0;
97 + lastCheckpointTime = DateTime.now();
98 + }
99 +
100 + void updateSavedMetrics(Message message) {
101 + lastSavedContentLength = message.content.length;
102 + lastSavedReasoningLength = message.reasoningContent?.length ?? 0;
103 + }
104 +
105 + void cancelUiTimer() {
106 + uiUpdateTimer?.cancel();
107 + uiUpdateTimer = null;
108 + }
109 +
110 + /// Stops delivering events for this reply and cancels the UI timer and the
111 + /// stream subscription. Does not cancel the underlying request.
112 + Future<void> detach() async {
113 + cancelled = true;
114 + cancelUiTimer();
115 + final sub = subscription;
116 + subscription = null;
117 + if (sub != null) {
118 + await sub.cancel();
119 + }
120 + }
121 +}
lib/features/chat/providers/model_selection_providers.dart
+16 -13
@@ -13,11 +13,12 @@ import 'package:localmind/features/on_device/data/models/on_device_model.dart';
13 import 'package:localmind/features/servers/data/models/server.dart';
14 import 'package:localmind/features/servers/providers/server_providers.dart';
15 import 'package:localmind/features/on_device/providers/on_device_providers.dart';
16 +import 'generation_session.dart' show ActiveGeneration;
17
18 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.';
19 +const onDeviceBusyMessage =
20 + 'The on-device model is still replying in another chat. Wait for it to '
21 + 'finish or stop it before sending here.';
22
23 class ActiveChatTarget {
24 final Server? server;
@@ -372,19 +373,21 @@ class IsStreamingNotifier extends Notifier<bool> {
373 }
374 }
375
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,
376 +/// Replies that are generating right now, keyed by conversation id in the
377 +/// order they started. Remote replies run concurrently and keep streaming in
378 +/// the background while another chat is open (#94); on-device replies share a
379 +/// single engine, so at most one of them appears here.
380 +final activeGenerationsProvider =
381 + NotifierProvider<ActiveGenerationsNotifier, Map<String, ActiveGeneration>>(
382 + ActiveGenerationsNotifier.new,
383 );
384
383 -class StreamingConversationIdNotifier extends Notifier<String?> {
385 +class ActiveGenerationsNotifier
386 + extends Notifier<Map<String, ActiveGeneration>> {
387 @override
385 - String? build() => null;
388 + Map<String, ActiveGeneration> build() => const {};
389
387 - void set(String? conversationId) {
388 - state = conversationId;
390 + void set(Map<String, ActiveGeneration> generations) {
391 + state = generations;
392 }
393 }
lib/features/chat/views/components/background_generation_notice.dart
+359 -43
@@ -1,22 +1,82 @@
1 +import 'dart:math' as math;
2 +import 'dart:ui' show lerpDouble;
3 +
4 +import 'package:cue/cue.dart';
5 import 'package:flutter/material.dart';
6 import 'package:flutter_riverpod/flutter_riverpod.dart';
7 import 'package:hugeicons/hugeicons.dart';
8 +import 'package:localmind/core/services/app_haptics.dart';
9 +import 'package:localmind/core/theme/colors.dart';
10 import 'package:localmind/l10n/app_localizations.dart';
11
12 import '../../../conversations/providers/conversation_providers.dart' as conv;
13 import '../../providers/chat_providers.dart';
14 +import 'heartbeat_dot.dart';
15 +
16 +/// Shown above the input while replies for other chats are still generating
17 +/// in the background (#94). Styled as a stacked card layer on top of the chat
18 +/// input bar with a circular progress indicator at the right.
19 +///
20 +/// When several remote chats are generating, tapping the notice expands it
21 +/// upwards to list them so the user can switch to any of them. Tapping a
22 +/// single generation, or the notice while the on-device engine blocks
23 +/// sending, opens that chat directly.
24 +class BackgroundGenerationNotice extends ConsumerStatefulWidget {
25 + const BackgroundGenerationNotice({
26 + super.key,
27 + required this.generations,
28 + required this.blocksSending,
29 + });
30
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});
31 + /// Replies running for chats other than the open one, in start order.
32 + final List<ActiveGeneration> generations;
33
14 - final String conversationId;
34 + /// True when the on-device engine is busy, so sending here has to wait.
35 + final bool blocksSending;
36
37 @override
17 - Widget build(BuildContext context, WidgetRef ref) {
38 + ConsumerState<BackgroundGenerationNotice> createState() =>
39 + _BackgroundGenerationNoticeState();
40 +}
41 +
42 +class _BackgroundGenerationNoticeState
43 + extends ConsumerState<BackgroundGenerationNotice> {
44 + bool _isExpanded = false;
45 +
46 + /// The list is only useful when several chats reply and nothing is blocked;
47 + /// a blocked send points at the one on-device chat to wait for instead.
48 + bool get _canExpand => widget.generations.length > 1 && !widget.blocksSending;
49 +
50 + @override
51 + void didUpdateWidget(covariant BackgroundGenerationNotice oldWidget) {
52 + super.didUpdateWidget(oldWidget);
53 + if (!_canExpand) _isExpanded = false;
54 + }
55 +
56 + void _toggleExpand() {
57 + ref.read(appHapticsProvider).light();
58 + setState(() => _isExpanded = !_isExpanded);
59 + }
60 +
61 + void _collapse() {
62 + if (!_isExpanded) return;
63 + setState(() => _isExpanded = false);
64 + }
65 +
66 + @override
67 + Widget build(BuildContext context) {
68 final l10n = AppLocalizations.of(context)!;
69 final theme = Theme.of(context);
70 + final isDark = theme.brightness == Brightness.dark;
71 + // Collapses on its own when the list stops being available.
72 + final canExpand = _canExpand;
73 + final isExpanded = _isExpanded && canExpand;
74 +
75 + final target = widget.blocksSending
76 + ? widget.generations.where((g) => g.isOnDevice).firstOrNull ??
77 + widget.generations.last
78 + : widget.generations.last;
79 + final conversationId = target.conversationId;
80 final conversation = ref.watch(
81 conv.conversationsProvider.select(
82 (value) =>
@@ -24,50 +84,306 @@ class BackgroundGenerationNotice extends ConsumerWidget {
84 ),
85 );
86 final title = conversation?.title.trim();
87 + final hasTitle = title != null && title.isNotEmpty;
88 + final message = widget.blocksSending
89 + ? (hasTitle
90 + ? l10n.background_generation_notice(title)
91 + : l10n.background_generation_notice_untitled)
92 + : canExpand
93 + ? l10n.background_generation_notice_multiple(widget.generations.length)
94 + : (hasTitle
95 + ? l10n.background_generation_notice_info(title)
96 + : l10n.background_generation_notice_info_untitled);
97
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,
98 + final collapsedHeight = canExpand ? 34.0 : 40.0;
99 + final targetExpandedHeight =
100 + (collapsedHeight + 6.0 + (widget.generations.length * 28.0)).clamp(
101 + collapsedHeight,
102 + 220.0,
103 + );
104 +
105 + return Cue.onMount(
106 + motion: .smooth(),
107 + acts: [.fadeIn(), .slideY(from: 0.15)],
108 + child: Cue.onToggle(
109 + toggled: isExpanded,
110 + motion: .smooth(),
111 + child: TweenActor<double>.value(
112 + from: 0,
113 + to: 1,
114 + builder: (context, value, _) {
115 + final t = value.clamp(0.0, 1.0);
116 + final visibleHeight = lerpDouble(
117 + collapsedHeight,
118 + targetExpandedHeight,
119 + t,
120 + )!;
121 + const overlap = 4.0;
122 + final containerHeight = visibleHeight + overlap;
123 +
124 + return SizedBox(
125 + height: visibleHeight,
126 + child: OverflowBox(
127 + alignment: Alignment.topCenter,
128 + minHeight: containerHeight,
129 + maxHeight: containerHeight,
130 + child: Padding(
131 + padding: const EdgeInsets.symmetric(horizontal: 24),
132 + child: Container(
133 + clipBehavior: Clip.antiAlias,
134 + decoration: BoxDecoration(
135 + color: isDark
136 + ? AppColors.darkSurfaceInput
137 + : AppColors.lightSurface,
138 + borderRadius: const BorderRadius.vertical(
139 + top: Radius.circular(16),
140 + ),
141 + border: Border.all(
142 + color: isDark
143 + ? AppColors.darkBorder.withValues(alpha: 0.6)
144 + : AppColors.lightBorder,
145 + width: 1,
146 + ),
147 + boxShadow: [
148 + BoxShadow(
149 + color: Colors.black.withValues(
150 + alpha: isDark ? 0.22 : 0.05,
151 + ),
152 + blurRadius: 10,
153 + offset: const Offset(0, -2),
154 + ),
155 + ],
156 + ),
157 + child: Column(
158 + mainAxisSize: MainAxisSize.min,
159 + children: [
160 + Material(
161 + color: Colors.transparent,
162 + child: InkWell(
163 + borderRadius: const BorderRadius.vertical(
164 + top: Radius.circular(16),
165 + ),
166 + onTap: () {
167 + if (canExpand) {
168 + _toggleExpand();
169 + } else if (conversation != null) {
170 + ref.read(appHapticsProvider).light();
171 + ref
172 + .read(chatProvider.notifier)
173 + .loadConversation(conversation);
174 + }
175 + },
176 + child: Padding(
177 + padding: const EdgeInsets.fromLTRB(12, 5, 12, 5),
178 + child: Row(
179 + children: [
180 + Container(
181 + width: 22,
182 + height: 22,
183 + decoration: BoxDecoration(
184 + color: theme.colorScheme.primary
185 + .withValues(alpha: 0.12),
186 + shape: BoxShape.circle,
187 + ),
188 + alignment: Alignment.center,
189 + child: HugeIcon(
190 + icon: HugeIcons.strokeRoundedBackground,
191 + size: 13,
192 + color: theme.colorScheme.primary,
193 + ),
194 + ),
195 + const SizedBox(width: 10),
196 + Expanded(
197 + child: Text(
198 + message,
199 + maxLines: 2,
200 + overflow: TextOverflow.ellipsis,
201 + style: theme.textTheme.bodySmall
202 + ?.copyWith(
203 + fontSize: 12,
204 + height: 1.25,
205 + color: theme.colorScheme.onSurface
206 + .withValues(alpha: 0.9),
207 + ),
208 + ),
209 + ),
210 + if (canExpand) ...[
211 + const SizedBox(width: 6),
212 + Transform.rotate(
213 + angle: t * math.pi,
214 + child: HugeIcon(
215 + icon: HugeIcons.strokeRoundedArrowUp01,
216 + size: 14,
217 + color: theme.colorScheme.primary,
218 + ),
219 + ),
220 + ],
221 + const SizedBox(width: 8),
222 + SizedBox(
223 + width: 15,
224 + height: 15,
225 + child: CircularProgressIndicator(
226 + strokeWidth: 2,
227 + color: theme.colorScheme.primary,
228 + ),
229 + ),
230 + ],
231 + ),
232 + ),
233 + ),
234 + ),
235 + if (t > 0.01)
236 + Expanded(
237 + child: ClipRect(
238 + child: SingleChildScrollView(
239 + physics: const ClampingScrollPhysics(),
240 + padding: const EdgeInsets.only(bottom: 2),
241 + child: Opacity(
242 + opacity: t,
243 + child: Column(
244 + mainAxisSize: MainAxisSize.min,
245 + children: [
246 + Divider(
247 + height: 4,
248 + thickness: 0.5,
249 + color: isDark
250 + ? AppColors.darkBorder.withValues(
251 + alpha: 0.6,
252 + )
253 + : AppColors.lightBorder,
254 + ),
255 + for (final gen in widget.generations)
256 + BackgroundGenerationChatItem(
257 + key: ValueKey(
258 + 'gen-item-${gen.conversationId}',
259 + ),
260 + generation: gen,
261 + onTap: () {
262 + final itemConv = ref.read(
263 + conv.conversationsProvider.select(
264 + (val) => val.value
265 + ?.where(
266 + (c) =>
267 + c.id ==
268 + gen.conversationId,
269 + )
270 + .firstOrNull,
271 + ),
272 + );
273 + if (itemConv != null) {
274 + ref
275 + .read(appHapticsProvider)
276 + .light();
277 + ref
278 + .read(chatProvider.notifier)
279 + .loadConversation(itemConv);
280 + _collapse();
281 + }
282 + },
283 + ),
284 + ],
285 + ),
286 + ),
287 + ),
288 + ),
289 + ),
290 + ],
291 + ),
292 ),
293 ),
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,
294 + ),
295 + );
296 + },
297 + ),
298 + ),
299 + );
300 + }
301 +}
302 +
303 +/// A single listed conversation item in the expanded background generation notice.
304 +class BackgroundGenerationChatItem extends ConsumerWidget {
305 + const BackgroundGenerationChatItem({
306 + super.key,
307 + required this.generation,
308 + required this.onTap,
309 + });
310 +
311 + final ActiveGeneration generation;
312 + final VoidCallback onTap;
313 +
314 + @override
315 + Widget build(BuildContext context, WidgetRef ref) {
316 + final l10n = AppLocalizations.of(context)!;
317 + final theme = Theme.of(context);
318 + final conversation = ref.watch(
319 + conv.conversationsProvider.select(
320 + (value) => value.value
321 + ?.where((c) => c.id == generation.conversationId)
322 + .firstOrNull,
323 + ),
324 + );
325 + final title = conversation?.title.trim();
326 + final displayTitle = (title != null && title.isNotEmpty)
327 + ? title
328 + : l10n.background_generation_chat_untitled;
329 +
330 + return Material(
331 + color: Colors.transparent,
332 + child: InkWell(
333 + borderRadius: BorderRadius.circular(8),
334 + onTap: conversation == null ? null : onTap,
335 + child: Padding(
336 + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
337 + child: Row(
338 + children: [
339 + HugeIcon(
340 + icon: HugeIcons.strokeRoundedChatting01,
341 + size: 15,
342 + color: theme.colorScheme.primary,
343 + ),
344 + const SizedBox(width: 8),
345 + Expanded(
346 + child: Text(
347 + displayTitle,
348 + maxLines: 1,
349 + overflow: TextOverflow.ellipsis,
350 + style: theme.textTheme.bodySmall?.copyWith(
351 + fontSize: 12,
352 + fontWeight: FontWeight.w500,
353 + color: theme.colorScheme.onSurface.withValues(alpha: 0.9),
354 ),
355 ),
63 - if (conversation != null)
64 - HugeIcon(
65 - icon: HugeIcons.strokeRoundedArrowRight01,
66 - size: 16,
67 - color: theme.colorScheme.primary,
356 + ),
357 + if (generation.isOnDevice) ...[
358 + const SizedBox(width: 6),
359 + Container(
360 + padding: const EdgeInsets.symmetric(
361 + horizontal: 5,
362 + vertical: 1.5,
363 + ),
364 + decoration: BoxDecoration(
365 + color: theme.colorScheme.primary.withValues(alpha: 0.1),
366 + borderRadius: BorderRadius.circular(4),
367 ),
368 + child: Text(
369 + l10n.server_type_on_device,
370 + style: TextStyle(
371 + fontSize: 10,
372 + fontWeight: FontWeight.w500,
373 + color: theme.colorScheme.primary,
374 + ),
375 + ),
376 + ),
377 ],
70 - ),
378 + const SizedBox(width: 8),
379 + const HeartbeatDot(size: 7),
380 + const SizedBox(width: 6),
381 + HugeIcon(
382 + icon: HugeIcons.strokeRoundedArrowRight01,
383 + size: 14,
384 + color: theme.colorScheme.onSurface.withValues(alpha: 0.35),
385 + ),
386 + ],
387 ),
388 ),
389 ),
lib/features/chat/views/components/chat_bottom_bar.dart
+19 -8
@@ -9,6 +9,7 @@ import 'package:localmind/features/chat/providers/chat_providers.dart';
9 import 'package:localmind/features/conversations/providers/conversation_providers.dart'
10 as conv;
11 import 'package:localmind/features/models/views/model_picker_sheet.dart';
12 +import 'package:localmind/features/servers/providers/server_providers.dart';
13 import 'package:localmind/l10n/app_localizations.dart';
14
15 class ChatBottomBar extends ConsumerWidget {
@@ -37,12 +38,20 @@ class ChatBottomBar extends ConsumerWidget {
38 final streamingId = ref.watch(
39 chatProvider.select((s) => s.streamingMessage?.id),
40 );
40 - // Another chat's reply is still generating in the background (#94).
41 - final generatingConversationId = ref.watch(streamingConversationIdProvider);
41 + // Replies generating for other chats keep streaming in the background
42 + // (#94). Remote servers can run several at once; the on-device engine
43 + // serves one reply at a time, so it blocks sending here.
44 + final generations = ref.watch(activeGenerationsProvider);
45 final activeConversationId = ref.watch(conv.activeConversationIdProvider);
43 - final generatingElsewhere =
44 - generatingConversationId != null &&
45 - generatingConversationId != activeConversationId;
46 + final activeServerIsOnDevice = ref.watch(
47 + activeServerProvider.select((server) => server?.isOnDevice ?? false),
48 + );
49 + final generatingElsewhere = [
50 + for (final generation in generations.values)
51 + if (generation.conversationId != activeConversationId) generation,
52 + ];
53 + final onDeviceBusy =
54 + activeServerIsOnDevice && generatingElsewhere.any((g) => g.isOnDevice);
55
56 int totalTokenCount = 0;
57 if (hasActiveConv && dbTokenCount != null && dbTokenCount > 0) {
@@ -91,14 +100,16 @@ class ChatBottomBar extends ConsumerWidget {
100 const SizedBox(height: 4),
101 const SmartReplyChipsWrapper(),
102 ],
94 - if (generatingElsewhere)
103 + if (generatingElsewhere.isNotEmpty)
104 BackgroundGenerationNotice(
96 - conversationId: generatingConversationId,
105 + generations: generatingElsewhere,
106 + blocksSending: onDeviceBusy,
107 ),
108 ChatInputBar(
109 focusNode: inputFocusNode,
110 isStreaming: isStreaming,
101 - sendBlocked: generatingElsewhere,
111 + sendBlocked: onDeviceBusy,
112 + hasNoticeAbove: generatingElsewhere.isNotEmpty,
113 keyboardIncognito: keyboardIncognito,
114 onSend: (message, {attachments}) {
115 ref
lib/features/chat/views/components/chat_input_bar.dart
+33 -3
@@ -12,6 +12,7 @@ import '../../../../core/services/app_haptics.dart';
12 import '../../../../core/utils/safe_file_picker.dart';
13 import '../../../../core/theme/colors.dart';
14 import '../../../../l10n/app_localizations.dart';
15 +import '../../../conversations/providers/conversation_providers.dart' as conv;
16 import '../../../saved_messages/views/components/saved_message_picker_sheet.dart';
17 import '../../../servers/providers/server_providers.dart';
18 import '../../../stt/providers/stt_providers.dart';
@@ -42,7 +43,7 @@ class ChatInputBar extends ConsumerStatefulWidget {
43 this.enabled = true,
44
45 this.sendBlocked = false,
45 -
46 + this.hasNoticeAbove = false,
47 this.isStreaming = false,
48
49 this.focusNode,
@@ -63,7 +64,7 @@ class ChatInputBar extends ConsumerStatefulWidget {
64 /// Typing stays possible, but a reply can't be requested yet (another
65 /// chat's reply is still generating in the background, #94).
66 final bool sendBlocked;
66 -
67 + final bool hasNoticeAbove;
68 final bool isStreaming;
69
70 final FocusNode? focusNode;
@@ -107,11 +108,18 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
108 late AnimationController _holdProgressController;
109
110 String _preSpeechText = '';
111 + String? _lastStreamingConversationId;
112
113 @override
114 void initState() {
115 super.initState();
116
117 + if (ref.read(isStreamingProvider)) {
118 + _lastStreamingConversationId = ref.read(
119 + conv.activeConversationIdProvider,
120 + );
121 + }
122 +
123 _focusNode = widget.focusNode ?? FocusNode();
124 _incognitoFocus = FocusNode();
125
@@ -846,7 +854,24 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
854 // if the State deactivates between the await and the next ref read.
855 // Schedule the actual restart on the microtask queue instead.
856 ref.listen<bool>(isStreamingProvider, (previous, next) {
857 + if (next == true) {
858 + _lastStreamingConversationId = ref.read(
859 + conv.activeConversationIdProvider,
860 + );
861 + return;
862 + }
863 if (previous != true || next != false || !mounted) return;
864 + // When the user switches from a streaming chat to a non-streaming one,
865 + // isStreamingProvider flips true → false because the *open* chat changed,
866 + // not because a reply finished. Ignore that flip so auto-speak doesn't
867 + // restart the mic on a chat switch (#94 concurrent generation).
868 + final priorConvId = _lastStreamingConversationId;
869 + _lastStreamingConversationId = null;
870 + if (priorConvId != null &&
871 + ref.read(activeGenerationsProvider).containsKey(priorConvId)) {
872 + return;
873 + }
874 +
875 final shouldAutoSpeak = ref.read(settingsProvider).autoSpeakEnabled;
876 final voiceState = ref.read(voiceModeProvider);
877 final voiceActive =
@@ -884,7 +909,12 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
909 return SafeArea(
910 top: false,
911 child: Container(
887 - margin: EdgeInsets.only(left: 12, right: 12, top: 8, bottom: 8),
912 + margin: EdgeInsets.only(
913 + left: 12,
914 + right: 12,
915 + top: (widget.sendBlocked || widget.hasNoticeAbove) ? 0 : 8,
916 + bottom: 8,
917 + ),
918
919 padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
920
lib/features/chat/views/components/heartbeat_dot.dart new
+137
@@ -0,0 +1,137 @@
1 +import 'package:flutter/material.dart';
2 +
3 +/// A subtle, organic animated dot with a dual-pulse "heartbeat" rhythm.
4 +///
5 +/// Designed as a compact, non-intrusive activity indicator inside chat list items
6 +/// when background generations are in progress.
7 +class HeartbeatDot extends StatefulWidget {
8 + const HeartbeatDot({super.key, this.size = 7.0, this.color});
9 +
10 + /// Diameter of the resting dot in pixels.
11 + final double size;
12 +
13 + /// Dot color. Defaults to [ColorScheme.primary].
14 + final Color? color;
15 +
16 + @override
17 + State<HeartbeatDot> createState() => _HeartbeatDotState();
18 +}
19 +
20 +class _HeartbeatDotState extends State<HeartbeatDot>
21 + with SingleTickerProviderStateMixin {
22 + late final AnimationController _controller;
23 + late final Animation<double> _scaleAnimation;
24 + late final Animation<double> _opacityAnimation;
25 +
26 + @override
27 + void initState() {
28 + super.initState();
29 + _controller = AnimationController(
30 + vsync: this,
31 + duration: const Duration(milliseconds: 1400),
32 + )..repeat();
33 +
34 + // Subtle rhythmic heartbeat pattern:
35 + // 0.00 - 0.16: First beat / Systole pulse (lub)
36 + // 0.16 - 0.30: Slight recoil
37 + // 0.30 - 0.44: Second beat (dub)
38 + // 0.44 - 0.62: Return to baseline
39 + // 0.62 - 1.00: Diastole resting pause
40 + _scaleAnimation = TweenSequence<double>([
41 + TweenSequenceItem(
42 + tween: Tween<double>(
43 + begin: 0.9,
44 + end: 1.25,
45 + ).chain(CurveTween(curve: Curves.easeOutCubic)),
46 + weight: 16,
47 + ),
48 + TweenSequenceItem(
49 + tween: Tween<double>(
50 + begin: 1.25,
51 + end: 1.02,
52 + ).chain(CurveTween(curve: Curves.easeInOutCubic)),
53 + weight: 14,
54 + ),
55 + TweenSequenceItem(
56 + tween: Tween<double>(
57 + begin: 1.02,
58 + end: 1.18,
59 + ).chain(CurveTween(curve: Curves.easeOutCubic)),
60 + weight: 14,
61 + ),
62 + TweenSequenceItem(
63 + tween: Tween<double>(
64 + begin: 1.18,
65 + end: 0.9,
66 + ).chain(CurveTween(curve: Curves.easeInOutCubic)),
67 + weight: 18,
68 + ),
69 + TweenSequenceItem(tween: ConstantTween<double>(0.9), weight: 38),
70 + ]).animate(_controller);
71 +
72 + _opacityAnimation = TweenSequence<double>([
73 + TweenSequenceItem(
74 + tween: Tween<double>(begin: 0.45, end: 1.0),
75 + weight: 16,
76 + ),
77 + TweenSequenceItem(tween: Tween<double>(begin: 1.0, end: 0.7), weight: 14),
78 + TweenSequenceItem(
79 + tween: Tween<double>(begin: 0.7, end: 0.95),
80 + weight: 14,
81 + ),
82 + TweenSequenceItem(
83 + tween: Tween<double>(begin: 0.95, end: 0.45),
84 + weight: 18,
85 + ),
86 + TweenSequenceItem(tween: ConstantTween<double>(0.45), weight: 38),
87 + ]).animate(_controller);
88 + }
89 +
90 + @override
91 + void dispose() {
92 + _controller.dispose();
93 + super.dispose();
94 + }
95 +
96 + @override
97 + Widget build(BuildContext context) {
98 + final dotColor = widget.color ?? Theme.of(context).colorScheme.primary;
99 + final containerDim = widget.size * 1.8;
100 +
101 + return SizedBox(
102 + width: containerDim,
103 + height: containerDim,
104 + child: Center(
105 + child: AnimatedBuilder(
106 + animation: _controller,
107 + builder: (context, _) {
108 + final scale = _scaleAnimation.value;
109 + final opacity = _opacityAnimation.value;
110 +
111 + return Transform.scale(
112 + scale: scale,
113 + child: Opacity(
114 + opacity: opacity,
115 + child: Container(
116 + width: widget.size,
117 + height: widget.size,
118 + decoration: BoxDecoration(
119 + color: dotColor,
120 + shape: BoxShape.circle,
121 + boxShadow: [
122 + BoxShadow(
123 + color: dotColor.withValues(alpha: 0.35 * opacity),
124 + blurRadius: 3,
125 + spreadRadius: 0.5,
126 + ),
127 + ],
128 + ),
129 + ),
130 + ),
131 + );
132 + },
133 + ),
134 + ),
135 + );
136 + }
137 +}
lib/features/conversations/views/components/conversation_list.dart
+8 -2
@@ -28,7 +28,11 @@ 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);
31 + final generatingConversationIds = ref.watch(
32 + activeGenerationsProvider.select(
33 + (generations) => generations.keys.toSet(),
34 + ),
35 + );
36 final selectionMode = ref.watch(historySelectionModeProvider);
37 final selectedIds = ref.watch(historySelectedIdsProvider);
38 final sectionOrder = [
@@ -74,7 +78,9 @@ class ConversationList extends ConsumerWidget {
78 isActive: activeConversation?.id == conversation.id,
79 selectionMode: selectionMode,
80 isSelected: selectedIds.contains(conversation.id),
77 - isGenerating: generatingConversationId == conversation.id,
81 + isGenerating: generatingConversationIds.contains(
82 + conversation.id,
83 + ),
84 onEnterSelectionMode: () {
85 ref.read(historySelectionModeProvider.notifier).enable();
86 ref
lib/features/settings/views/settings_screen.dart
+3 -8
@@ -510,14 +510,9 @@ class SettingsViews extends ConsumerWidget {
510 label: l10n.delete_all_conversations,
511 icon: HugeIcons.strokeRoundedDelete01,
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 - }
513 + await ref
514 + .read(chatProvider.notifier)
515 + .cancelAllGenerations();
516 await ref
517 .read(conversationsProvider.notifier)
518 .deleteAll();
lib/l10n/app_ar.arb
+5 -1
@@ -954,5 +954,9 @@
954 "default_system_prompt_desc": "يُستخدم في المحادثات التي لا تحتوي على شخصية ولا موجّه نظام خاص بها.",
955 "default_system_prompt_hint": "أنت مساعد مفيد…",
956 "background_generation_notice": "لا يزال الرد جاريًا في \"{title}\". يمكنك الإرسال هنا بعد انتهائه. اضغط للفتح.",
957 - "background_generation_notice_untitled": "لا تزال محادثة أخرى تُنشئ ردًا. يمكنك الإرسال هنا بعد انتهائها. اضغط للفتح."
957 + "background_generation_notice_untitled": "لا تزال محادثة أخرى تُنشئ ردًا. يمكنك الإرسال هنا بعد انتهائها. اضغط للفتح.",
958 + "background_generation_notice_info": "يتم الرد في \"{title}\" في الخلفية. اضغط للفتح.",
959 + "background_generation_notice_info_untitled": "محادثة أخرى تُنشئ ردًا في الخلفية. اضغط للفتح.",
960 + "background_generation_notice_multiple": "{count} محادثات تُنشئ ردودًا في الخلفية.",
961 + "background_generation_chat_untitled": "محادثة بلا عنوان"
962 }
lib/l10n/app_bn.arb
+5 -1
@@ -950,5 +950,9 @@
950 "default_system_prompt_desc": "যে চ্যাটে কোনো পারসোনা বা নিজস্ব সিস্টেম প্রম্পট নেই সেখানে ব্যবহৃত হয়।",
951 "default_system_prompt_hint": "তুমি একজন সহায়ক অ্যাসিস্ট্যান্ট…",
952 "background_generation_notice": "\"{title}\"-এ এখনও উত্তর তৈরি হচ্ছে। শেষ হলে এখানে পাঠাতে পারবেন। খুলতে ট্যাপ করুন।",
953 - "background_generation_notice_untitled": "অন্য একটি চ্যাটে এখনও উত্তর তৈরি হচ্ছে। শেষ হলে এখানে পাঠাতে পারবেন। খুলতে ট্যাপ করুন।"
953 + "background_generation_notice_untitled": "অন্য একটি চ্যাটে এখনও উত্তর তৈরি হচ্ছে। শেষ হলে এখানে পাঠাতে পারবেন। খুলতে ট্যাপ করুন।",
954 + "background_generation_notice_info": "ব্যাকগ্রাউন্ডে \"{title}\"-এ উত্তর তৈরি হচ্ছে। খুলতে ট্যাপ করুন।",
955 + "background_generation_notice_info_untitled": "ব্যাকগ্রাউন্ডে অন্য একটি চ্যাটে উত্তর তৈরি হচ্ছে। খুলতে ট্যাপ করুন।",
956 + "background_generation_notice_multiple": "{count}টি চ্যাটে ব্যাকগ্রাউন্ডে উত্তর তৈরি হচ্ছে।",
957 + "background_generation_chat_untitled": "শিরোনামহীন চ্যাট"
958 }
lib/l10n/app_de.arb
+5 -1
@@ -947,5 +947,9 @@
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…",
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."
950 + "background_generation_notice_untitled": "Ein anderer Chat antwortet noch. Du kannst hier senden, sobald er fertig ist. Tippen zum Öffnen.",
951 + "background_generation_notice_info": "Antwort in „{title}“ im Hintergrund. Tippen zum Öffnen.",
952 + "background_generation_notice_info_untitled": "Ein anderer Chat antwortet im Hintergrund. Tippen zum Öffnen.",
953 + "background_generation_notice_multiple": "{count} Chats antworten im Hintergrund.",
954 + "background_generation_chat_untitled": "Chat ohne Titel"
955 }
lib/l10n/app_en.arb
+7 -1
@@ -3472,5 +3472,11 @@
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."
3475 + "background_generation_notice_untitled": "Another chat is still replying. You can send here once it finishes. Tap to open.",
3476 + "background_generation_notice_info": "Replying in \"{title}\" in the background. Tap to open.",
3477 + "@background_generation_notice_info": {"description": "Non-blocking notice when a remote chat is replying in the background", "placeholders": {"title": {"type": "String"}}},
3478 + "background_generation_notice_info_untitled": "Another chat is replying in the background. Tap to open.",
3479 + "background_generation_notice_multiple": "{count} chats are replying in the background.",
3480 + "@background_generation_notice_multiple": {"description": "Notice when multiple chats are replying in the background", "placeholders": {"count": {"type": "int"}}},
3481 + "background_generation_chat_untitled": "Untitled chat"
3482 }
lib/l10n/app_es.arb
+5 -1
@@ -950,5 +950,9 @@
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…",
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."
953 + "background_generation_notice_untitled": "Otro chat aún está respondiendo. Podrás enviar aquí cuando termine. Toca para abrir.",
954 + "background_generation_notice_info": "Respondiendo en \"{title}\" en segundo plano. Toca para abrir.",
955 + "background_generation_notice_info_untitled": "Otro chat está respondiendo en segundo plano. Toca para abrir.",
956 + "background_generation_notice_multiple": "{count} chats están respondiendo en segundo plano.",
957 + "background_generation_chat_untitled": "Chat sin título"
958 }
lib/l10n/app_fr.arb
+5 -1
@@ -947,5 +947,9 @@
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…",
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."
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 + "background_generation_notice_info": "Réponse en cours dans « {title} » en arrière-plan. Touchez pour ouvrir.",
952 + "background_generation_notice_info_untitled": "Une autre discussion répond en arrière-plan. Touchez pour ouvrir.",
953 + "background_generation_notice_multiple": "{count} discussions répondent en arrière-plan.",
954 + "background_generation_chat_untitled": "Discussion sans titre"
955 }
lib/l10n/app_hi.arb
+5 -1
@@ -950,5 +950,9 @@
950 "default_system_prompt_desc": "उन चैट के लिए उपयोग होता है जिनमें न कोई पर्सोना है और न ही अपना सिस्टम प्रॉम्प्ट।",
951 "default_system_prompt_hint": "आप एक सहायक असिस्टेंट हैं…",
952 "background_generation_notice": "\"{title}\" में अभी जवाब बन रहा है। पूरा होने पर आप यहाँ भेज सकेंगे। खोलने के लिए टैप करें।",
953 - "background_generation_notice_untitled": "कोई दूसरी चैट अभी जवाब बना रही है। पूरा होने पर आप यहाँ भेज सकेंगे। खोलने के लिए टैप करें।"
953 + "background_generation_notice_untitled": "कोई दूसरी चैट अभी जवाब बना रही है। पूरा होने पर आप यहाँ भेज सकेंगे। खोलने के लिए टैप करें।",
954 + "background_generation_notice_info": "बैकग्राउंड में \"{title}\" में जवाब बन रहा है। खोलने के लिए टैप करें।",
955 + "background_generation_notice_info_untitled": "बैकग्राउंड में कोई दूसरी चैट जवाब बना रही है। खोलने के लिए टैप करें।",
956 + "background_generation_notice_multiple": "{count} चैट बैकग्राउंड में जवाब बना रही हैं।",
957 + "background_generation_chat_untitled": "बिना शीर्षक की चैट"
958 }
lib/l10n/app_it.arb
+5 -1
@@ -950,5 +950,9 @@
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…",
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."
953 + "background_generation_notice_untitled": "Un'altra chat sta ancora rispondendo. Potrai inviare qui quando termina. Tocca per aprire.",
954 + "background_generation_notice_info": "Risposta in corso in \"{title}\" in background. Tocca per aprire.",
955 + "background_generation_notice_info_untitled": "Un'altra chat sta rispondendo in background. Tocca per aprire.",
956 + "background_generation_notice_multiple": "{count} chat stanno rispondendo in background.",
957 + "background_generation_chat_untitled": "Chat senza titolo"
958 }
lib/l10n/app_ja.arb
+5 -1
@@ -950,5 +950,9 @@
950 "default_system_prompt_desc": "ペルソナもチャット固有のシステムプロンプトもないチャットで使用されます。",
951 "default_system_prompt_hint": "あなたは親切なアシスタントです…",
952 "background_generation_notice": "「{title}」でまだ返信を生成中です。完了するとここで送信できます。タップして開く。",
953 - "background_generation_notice_untitled": "別のチャットでまだ返信を生成中です。完了するとここで送信できます。タップして開く。"
953 + "background_generation_notice_untitled": "別のチャットでまだ返信を生成中です。完了するとここで送信できます。タップして開く。",
954 + "background_generation_notice_info": "バックグラウンドで「{title}」に返信中です。タップして開く。",
955 + "background_generation_notice_info_untitled": "バックグラウンドで別のチャットが返信中です。タップして開く。",
956 + "background_generation_notice_multiple": "{count}件のチャットがバックグラウンドで返信中です。",
957 + "background_generation_chat_untitled": "無題のチャット"
958 }
lib/l10n/app_ko.arb
+5 -1
@@ -961,5 +961,9 @@
961 "default_system_prompt_desc": "페르소나와 채팅별 시스템 프롬프트가 없는 채팅에 사용됩니다.",
962 "default_system_prompt_hint": "당신은 도움이 되는 어시스턴트입니다…",
963 "background_generation_notice": "\"{title}\"에서 아직 답변을 생성 중입니다. 완료되면 여기서 보낼 수 있습니다. 탭하여 열기.",
964 - "background_generation_notice_untitled": "다른 채팅에서 아직 답변을 생성 중입니다. 완료되면 여기서 보낼 수 있습니다. 탭하여 열기."
964 + "background_generation_notice_untitled": "다른 채팅에서 아직 답변을 생성 중입니다. 완료되면 여기서 보낼 수 있습니다. 탭하여 열기.",
965 + "background_generation_notice_info": "백그라운드에서 \"{title}\"에 답변 생성 중. 탭하여 열기.",
966 + "background_generation_notice_info_untitled": "백그라운드에서 다른 채팅이 답변 생성 중. 탭하여 열기.",
967 + "background_generation_notice_multiple": "{count}개의 채팅이 백그라운드에서 답변 생성 중.",
968 + "background_generation_chat_untitled": "제목 없는 채팅"
969 }
lib/l10n/app_localizations.dart
+24
@@ -5960,6 +5960,30 @@ abstract class AppLocalizations {
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 + /// Non-blocking notice when a remote chat is replying in the background
5965 + ///
5966 + /// In en, this message translates to:
5967 + /// **'Replying in \"{title}\" in the background. Tap to open.'**
5968 + String background_generation_notice_info(String title);
5969 +
5970 + /// No description provided for @background_generation_notice_info_untitled.
5971 + ///
5972 + /// In en, this message translates to:
5973 + /// **'Another chat is replying in the background. Tap to open.'**
5974 + String get background_generation_notice_info_untitled;
5975 +
5976 + /// Notice when multiple chats are replying in the background
5977 + ///
5978 + /// In en, this message translates to:
5979 + /// **'{count} chats are replying in the background.'**
5980 + String background_generation_notice_multiple(int count);
5981 +
5982 + /// No description provided for @background_generation_chat_untitled.
5983 + ///
5984 + /// In en, this message translates to:
5985 + /// **'Untitled chat'**
5986 + String get background_generation_chat_untitled;
5987 }
5988
5989 class _AppLocalizationsDelegate
lib/l10n/app_localizations_ar.dart
+17
@@ -3207,4 +3207,21 @@ class AppLocalizationsAr extends AppLocalizations {
3207 @override
3208 String get background_generation_notice_untitled =>
3209 'لا تزال محادثة أخرى تُنشئ ردًا. يمكنك الإرسال هنا بعد انتهائها. اضغط للفتح.';
3210 +
3211 + @override
3212 + String background_generation_notice_info(String title) {
3213 + return 'يتم الرد في \"$title\" في الخلفية. اضغط للفتح.';
3214 + }
3215 +
3216 + @override
3217 + String get background_generation_notice_info_untitled =>
3218 + 'محادثة أخرى تُنشئ ردًا في الخلفية. اضغط للفتح.';
3219 +
3220 + @override
3221 + String background_generation_notice_multiple(int count) {
3222 + return '$count محادثات تُنشئ ردودًا في الخلفية.';
3223 + }
3224 +
3225 + @override
3226 + String get background_generation_chat_untitled => 'محادثة بلا عنوان';
3227 }
lib/l10n/app_localizations_bn.dart
+17
@@ -3232,4 +3232,21 @@ class AppLocalizationsBn extends AppLocalizations {
3232 @override
3233 String get background_generation_notice_untitled =>
3234 'অন্য একটি চ্যাটে এখনও উত্তর তৈরি হচ্ছে। শেষ হলে এখানে পাঠাতে পারবেন। খুলতে ট্যাপ করুন।';
3235 +
3236 + @override
3237 + String background_generation_notice_info(String title) {
3238 + return 'ব্যাকগ্রাউন্ডে \"$title\"-এ উত্তর তৈরি হচ্ছে। খুলতে ট্যাপ করুন।';
3239 + }
3240 +
3241 + @override
3242 + String get background_generation_notice_info_untitled =>
3243 + 'ব্যাকগ্রাউন্ডে অন্য একটি চ্যাটে উত্তর তৈরি হচ্ছে। খুলতে ট্যাপ করুন।';
3244 +
3245 + @override
3246 + String background_generation_notice_multiple(int count) {
3247 + return '$countটি চ্যাটে ব্যাকগ্রাউন্ডে উত্তর তৈরি হচ্ছে।';
3248 + }
3249 +
3250 + @override
3251 + String get background_generation_chat_untitled => 'শিরোনামহীন চ্যাট';
3252 }
lib/l10n/app_localizations_de.dart
+17
@@ -3222,4 +3222,21 @@ class AppLocalizationsDe extends AppLocalizations {
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 +
3226 + @override
3227 + String background_generation_notice_info(String title) {
3228 + return 'Antwort in „$title“ im Hintergrund. Tippen zum Öffnen.';
3229 + }
3230 +
3231 + @override
3232 + String get background_generation_notice_info_untitled =>
3233 + 'Ein anderer Chat antwortet im Hintergrund. Tippen zum Öffnen.';
3234 +
3235 + @override
3236 + String background_generation_notice_multiple(int count) {
3237 + return '$count Chats antworten im Hintergrund.';
3238 + }
3239 +
3240 + @override
3241 + String get background_generation_chat_untitled => 'Chat ohne Titel';
3242 }
lib/l10n/app_localizations_en.dart
+17
@@ -3210,4 +3210,21 @@ class AppLocalizationsEn extends AppLocalizations {
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 +
3214 + @override
3215 + String background_generation_notice_info(String title) {
3216 + return 'Replying in \"$title\" in the background. Tap to open.';
3217 + }
3218 +
3219 + @override
3220 + String get background_generation_notice_info_untitled =>
3221 + 'Another chat is replying in the background. Tap to open.';
3222 +
3223 + @override
3224 + String background_generation_notice_multiple(int count) {
3225 + return '$count chats are replying in the background.';
3226 + }
3227 +
3228 + @override
3229 + String get background_generation_chat_untitled => 'Untitled chat';
3230 }
lib/l10n/app_localizations_es.dart
+17
@@ -3236,4 +3236,21 @@ class AppLocalizationsEs extends AppLocalizations {
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 +
3240 + @override
3241 + String background_generation_notice_info(String title) {
3242 + return 'Respondiendo en \"$title\" en segundo plano. Toca para abrir.';
3243 + }
3244 +
3245 + @override
3246 + String get background_generation_notice_info_untitled =>
3247 + 'Otro chat está respondiendo en segundo plano. Toca para abrir.';
3248 +
3249 + @override
3250 + String background_generation_notice_multiple(int count) {
3251 + return '$count chats están respondiendo en segundo plano.';
3252 + }
3253 +
3254 + @override
3255 + String get background_generation_chat_untitled => 'Chat sin título';
3256 }
lib/l10n/app_localizations_fr.dart
+17
@@ -3225,4 +3225,21 @@ class AppLocalizationsFr extends AppLocalizations {
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 +
3229 + @override
3230 + String background_generation_notice_info(String title) {
3231 + return 'Réponse en cours dans « $title » en arrière-plan. Touchez pour ouvrir.';
3232 + }
3233 +
3234 + @override
3235 + String get background_generation_notice_info_untitled =>
3236 + 'Une autre discussion répond en arrière-plan. Touchez pour ouvrir.';
3237 +
3238 + @override
3239 + String background_generation_notice_multiple(int count) {
3240 + return '$count discussions répondent en arrière-plan.';
3241 + }
3242 +
3243 + @override
3244 + String get background_generation_chat_untitled => 'Discussion sans titre';
3245 }
lib/l10n/app_localizations_hi.dart
+17
@@ -3222,4 +3222,21 @@ class AppLocalizationsHi extends AppLocalizations {
3222 @override
3223 String get background_generation_notice_untitled =>
3224 'कोई दूसरी चैट अभी जवाब बना रही है। पूरा होने पर आप यहाँ भेज सकेंगे। खोलने के लिए टैप करें।';
3225 +
3226 + @override
3227 + String background_generation_notice_info(String title) {
3228 + return 'बैकग्राउंड में \"$title\" में जवाब बन रहा है। खोलने के लिए टैप करें।';
3229 + }
3230 +
3231 + @override
3232 + String get background_generation_notice_info_untitled =>
3233 + 'बैकग्राउंड में कोई दूसरी चैट जवाब बना रही है। खोलने के लिए टैप करें।';
3234 +
3235 + @override
3236 + String background_generation_notice_multiple(int count) {
3237 + return '$count चैट बैकग्राउंड में जवाब बना रही हैं।';
3238 + }
3239 +
3240 + @override
3241 + String get background_generation_chat_untitled => 'बिना शीर्षक की चैट';
3242 }
lib/l10n/app_localizations_it.dart
+17
@@ -3238,4 +3238,21 @@ class AppLocalizationsIt extends AppLocalizations {
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 +
3242 + @override
3243 + String background_generation_notice_info(String title) {
3244 + return 'Risposta in corso in \"$title\" in background. Tocca per aprire.';
3245 + }
3246 +
3247 + @override
3248 + String get background_generation_notice_info_untitled =>
3249 + 'Un\'altra chat sta rispondendo in background. Tocca per aprire.';
3250 +
3251 + @override
3252 + String background_generation_notice_multiple(int count) {
3253 + return '$count chat stanno rispondendo in background.';
3254 + }
3255 +
3256 + @override
3257 + String get background_generation_chat_untitled => 'Chat senza titolo';
3258 }
lib/l10n/app_localizations_ja.dart
+17
@@ -3155,4 +3155,21 @@ class AppLocalizationsJa extends AppLocalizations {
3155 @override
3156 String get background_generation_notice_untitled =>
3157 '別のチャットでまだ返信を生成中です。完了するとここで送信できます。タップして開く。';
3158 +
3159 + @override
3160 + String background_generation_notice_info(String title) {
3161 + return 'バックグラウンドで「$title」に返信中です。タップして開く。';
3162 + }
3163 +
3164 + @override
3165 + String get background_generation_notice_info_untitled =>
3166 + 'バックグラウンドで別のチャットが返信中です。タップして開く。';
3167 +
3168 + @override
3169 + String background_generation_notice_multiple(int count) {
3170 + return '$count件のチャットがバックグラウンドで返信中です。';
3171 + }
3172 +
3173 + @override
3174 + String get background_generation_chat_untitled => '無題のチャット';
3175 }
lib/l10n/app_localizations_ko.dart
+17
@@ -3137,4 +3137,21 @@ class AppLocalizationsKo extends AppLocalizations {
3137 @override
3138 String get background_generation_notice_untitled =>
3139 '다른 채팅에서 아직 답변을 생성 중입니다. 완료되면 여기서 보낼 수 있습니다. 탭하여 열기.';
3140 +
3141 + @override
3142 + String background_generation_notice_info(String title) {
3143 + return '백그라운드에서 \"$title\"에 답변 생성 중. 탭하여 열기.';
3144 + }
3145 +
3146 + @override
3147 + String get background_generation_notice_info_untitled =>
3148 + '백그라운드에서 다른 채팅이 답변 생성 중. 탭하여 열기.';
3149 +
3150 + @override
3151 + String background_generation_notice_multiple(int count) {
3152 + return '$count개의 채팅이 백그라운드에서 답변 생성 중.';
3153 + }
3154 +
3155 + @override
3156 + String get background_generation_chat_untitled => '제목 없는 채팅';
3157 }
lib/l10n/app_localizations_pt.dart
+17
@@ -3253,4 +3253,21 @@ class AppLocalizationsPt extends AppLocalizations {
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 +
3257 + @override
3258 + String background_generation_notice_info(String title) {
3259 + return 'Respondendo em \"$title\" em segundo plano. Toque para abrir.';
3260 + }
3261 +
3262 + @override
3263 + String get background_generation_notice_info_untitled =>
3264 + 'Outro chat está respondendo em segundo plano. Toque para abrir.';
3265 +
3266 + @override
3267 + String background_generation_notice_multiple(int count) {
3268 + return '$count chats estão respondendo em segundo plano.';
3269 + }
3270 +
3271 + @override
3272 + String get background_generation_chat_untitled => 'Chat sem título';
3273 }
lib/l10n/app_localizations_ru.dart
+17
@@ -3235,4 +3235,21 @@ class AppLocalizationsRu extends AppLocalizations {
3235 @override
3236 String get background_generation_notice_untitled =>
3237 'Другой чат ещё генерирует ответ. Отправить здесь можно будет после завершения. Нажмите, чтобы открыть.';
3238 +
3239 + @override
3240 + String background_generation_notice_info(String title) {
3241 + return 'Ответ в «$title» генерируется в фоне. Нажмите, чтобы открыть.';
3242 + }
3243 +
3244 + @override
3245 + String get background_generation_notice_info_untitled =>
3246 + 'Другой чат отвечает в фоне. Нажмите, чтобы открыть.';
3247 +
3248 + @override
3249 + String background_generation_notice_multiple(int count) {
3250 + return '$count чатов отвечают в фоне.';
3251 + }
3252 +
3253 + @override
3254 + String get background_generation_chat_untitled => 'Чат без названия';
3255 }
lib/l10n/app_localizations_tr.dart
+17
@@ -3242,4 +3242,21 @@ class AppLocalizationsTr extends AppLocalizations {
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 +
3246 + @override
3247 + String background_generation_notice_info(String title) {
3248 + return 'Arka planda \"$title\" sohbetinde yanıt oluşturuluyor. Açmak için dokunun.';
3249 + }
3250 +
3251 + @override
3252 + String get background_generation_notice_info_untitled =>
3253 + 'Arka planda başka bir sohbet yanıt oluşturuyor. Açmak için dokunun.';
3254 +
3255 + @override
3256 + String background_generation_notice_multiple(int count) {
3257 + return '$count sohbet arka planda yanıt oluşturuyor.';
3258 + }
3259 +
3260 + @override
3261 + String get background_generation_chat_untitled => 'Başlıksız sohbet';
3262 }
lib/l10n/app_localizations_zh.dart
+32
@@ -3128,6 +3128,22 @@ class AppLocalizationsZh extends AppLocalizations {
3128 @override
3129 String get background_generation_notice_untitled =>
3130 '另一个对话仍在生成回复。完成后即可在此发送。点按打开。';
3131 +
3132 + @override
3133 + String background_generation_notice_info(String title) {
3134 + return '正在后台回复“$title”。点按打开。';
3135 + }
3136 +
3137 + @override
3138 + String get background_generation_notice_info_untitled => '另一个对话正在后台回复。点按打开。';
3139 +
3140 + @override
3141 + String background_generation_notice_multiple(int count) {
3142 + return '$count 个对话正在后台回复。';
3143 + }
3144 +
3145 + @override
3146 + String get background_generation_chat_untitled => '未命名对话';
3147 }
3148
3149 /// The translations for Chinese, as used in Taiwan (`zh_TW`).
@@ -6160,4 +6176,20 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
6176 @override
6177 String get background_generation_notice_untitled =>
6178 '另一個對話仍在產生回覆。完成後即可在此傳送。點按開啟。';
6179 +
6180 + @override
6181 + String background_generation_notice_info(String title) {
6182 + return '正在背景回覆「$title」。點按開啟。';
6183 + }
6184 +
6185 + @override
6186 + String get background_generation_notice_info_untitled => '另一個對話正在背景回覆。點按開啟。';
6187 +
6188 + @override
6189 + String background_generation_notice_multiple(int count) {
6190 + return '$count 個對話正在背景回覆。';
6191 + }
6192 +
6193 + @override
6194 + String get background_generation_chat_untitled => '未命名對話';
6195 }
lib/l10n/app_pt.arb
+5 -1
@@ -961,5 +961,9 @@
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…",
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."
964 + "background_generation_notice_untitled": "Outro chat ainda está respondendo. Você poderá enviar aqui quando terminar. Toque para abrir.",
965 + "background_generation_notice_info": "Respondendo em \"{title}\" em segundo plano. Toque para abrir.",
966 + "background_generation_notice_info_untitled": "Outro chat está respondendo em segundo plano. Toque para abrir.",
967 + "background_generation_notice_multiple": "{count} chats estão respondendo em segundo plano.",
968 + "background_generation_chat_untitled": "Chat sem título"
969 }
lib/l10n/app_ru.arb
+5 -1
@@ -947,5 +947,9 @@
947 "default_system_prompt_desc": "Используется в чатах без персоны и без собственного системного промпта.",
948 "default_system_prompt_hint": "Ты полезный ассистент…",
949 "background_generation_notice": "Ответ в «{title}» ещё генерируется. Отправить здесь можно будет после завершения. Нажмите, чтобы открыть.",
950 - "background_generation_notice_untitled": "Другой чат ещё генерирует ответ. Отправить здесь можно будет после завершения. Нажмите, чтобы открыть."
950 + "background_generation_notice_untitled": "Другой чат ещё генерирует ответ. Отправить здесь можно будет после завершения. Нажмите, чтобы открыть.",
951 + "background_generation_notice_info": "Ответ в «{title}» генерируется в фоне. Нажмите, чтобы открыть.",
952 + "background_generation_notice_info_untitled": "Другой чат отвечает в фоне. Нажмите, чтобы открыть.",
953 + "background_generation_notice_multiple": "{count} чатов отвечают в фоне.",
954 + "background_generation_chat_untitled": "Чат без названия"
955 }
lib/l10n/app_tr.arb
+5 -1
@@ -947,5 +947,9 @@
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…",
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."
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 + "background_generation_notice_info": "Arka planda \"{title}\" sohbetinde yanıt oluşturuluyor. Açmak için dokunun.",
952 + "background_generation_notice_info_untitled": "Arka planda başka bir sohbet yanıt oluşturuyor. Açmak için dokunun.",
953 + "background_generation_notice_multiple": "{count} sohbet arka planda yanıt oluşturuyor.",
954 + "background_generation_chat_untitled": "Başlıksız sohbet"
955 }
lib/l10n/app_zh.arb
+5 -1
@@ -950,5 +950,9 @@
950 "default_system_prompt_desc": "用于没有角色且没有专属系统提示词的对话。",
951 "default_system_prompt_hint": "你是一个乐于助人的助手……",
952 "background_generation_notice": "“{title}”仍在生成回复。完成后即可在此发送。点按打开。",
953 - "background_generation_notice_untitled": "另一个对话仍在生成回复。完成后即可在此发送。点按打开。"
953 + "background_generation_notice_untitled": "另一个对话仍在生成回复。完成后即可在此发送。点按打开。",
954 + "background_generation_notice_info": "正在后台回复“{title}”。点按打开。",
955 + "background_generation_notice_info_untitled": "另一个对话正在后台回复。点按打开。",
956 + "background_generation_notice_multiple": "{count} 个对话正在后台回复。",
957 + "background_generation_chat_untitled": "未命名对话"
958 }
lib/l10n/app_zh_TW.arb
+5 -1
@@ -950,5 +950,9 @@
950 "default_system_prompt_desc": "用於沒有角色且沒有專屬系統提示詞的對話。",
951 "default_system_prompt_hint": "你是一個樂於助人的助理……",
952 "background_generation_notice": "「{title}」仍在產生回覆。完成後即可在此傳送。點按開啟。",
953 - "background_generation_notice_untitled": "另一個對話仍在產生回覆。完成後即可在此傳送。點按開啟。"
953 + "background_generation_notice_untitled": "另一個對話仍在產生回覆。完成後即可在此傳送。點按開啟。",
954 + "background_generation_notice_info": "正在背景回覆「{title}」。點按開啟。",
955 + "background_generation_notice_info_untitled": "另一個對話正在背景回覆。點按開啟。",
956 + "background_generation_notice_multiple": "{count} 個對話正在背景回覆。",
957 + "background_generation_chat_untitled": "未命名對話"
958 }
lib/main.dart
+6
@@ -59,6 +59,12 @@ Future<void> main() async {
59 };
60
61 ErrorWidget.builder = (details) {
62 + if (CrashReportService.isBenignFrameworkError(
63 + details.exception,
64 + details.stack,
65 + )) {
66 + return const SizedBox.shrink();
67 + }
68 final captured = crashReports.capture(
69 details.exception,
70 details.stack ?? StackTrace.current,
test/core/components/server_logo_test.dart
+23 -18
@@ -31,12 +31,12 @@ void main() {
31 });
32
33 group('ServerLogo widget', () {
34 - testWidgets('renders Image for server types with brand logos', (tester) async {
34 + testWidgets('renders Image for server types with brand logos', (
35 + tester,
36 + ) async {
37 await tester.pumpWidget(
38 const MaterialApp(
37 - home: Scaffold(
38 - body: ServerLogo(type: ServerType.lmStudio, size: 24),
39 - ),
39 + home: Scaffold(body: ServerLogo(type: ServerType.lmStudio, size: 24)),
40 ),
41 );
42
@@ -44,7 +44,9 @@ void main() {
44 expect(find.byType(HugeIcon), findsNothing);
45 });
46
47 - testWidgets('renders HugeIcon for server types without brand logos', (tester) async {
47 + testWidgets('renders HugeIcon for server types without brand logos', (
48 + tester,
49 + ) async {
50 await tester.pumpWidget(
51 const MaterialApp(
52 home: Scaffold(
@@ -57,21 +59,24 @@ void main() {
59 expect(find.byType(Image), findsNothing);
60 });
61
60 - testWidgets('renders custom HugeIcon when customIcon is explicitly provided', (tester) async {
61 - await tester.pumpWidget(
62 - const MaterialApp(
63 - home: Scaffold(
64 - body: ServerLogo(
65 - type: ServerType.lmStudio,
66 - customIcon: HugeIcons.strokeRoundedStar,
67 - size: 24,
62 + testWidgets(
63 + 'renders custom HugeIcon when customIcon is explicitly provided',
64 + (tester) async {
65 + await tester.pumpWidget(
66 + const MaterialApp(
67 + home: Scaffold(
68 + body: ServerLogo(
69 + type: ServerType.lmStudio,
70 + customIcon: HugeIcons.strokeRoundedStar,
71 + size: 24,
72 + ),
73 ),
74 ),
70 - ),
71 - );
75 + );
76
73 - expect(find.byType(HugeIcon), findsOneWidget);
74 - expect(find.byType(Image), findsNothing);
75 - });
77 + expect(find.byType(HugeIcon), findsOneWidget);
78 + expect(find.byType(Image), findsNothing);
79 + },
80 + );
81 });
82 }
test/core/services/crash_report_service_test.dart
+21
@@ -1,4 +1,5 @@
1 import 'package:characters/characters.dart';
2 +import 'package:flutter/foundation.dart';
3 import 'package:flutter_test/flutter_test.dart';
4 import 'package:localmind/core/services/crash_report_service.dart';
5
@@ -213,5 +214,25 @@ void main() {
214 isFalse,
215 );
216 });
217 +
218 + test('ignores RenderFlex overflow errors', () {
219 + final flexOverflowError = FlutterError(
220 + 'A RenderFlex overflowed by 0.148 pixels on the bottom.',
221 + );
222 + expect(
223 + CrashReportService.isBenignFrameworkError(flexOverflowError),
224 + isTrue,
225 + );
226 + });
227 +
228 + test('ignores general pixel overflow errors', () {
229 + final generalOverflow = FlutterError(
230 + 'The relevant error-causing widget was: Column\noverflowed by 12.5 pixels',
231 + );
232 + expect(
233 + CrashReportService.isBenignFrameworkError(generalOverflow),
234 + isTrue,
235 + );
236 + });
237 });
238 }
test/features/chat/background_generation_notice_test.dart new
+450
@@ -0,0 +1,450 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_localizations/flutter_localizations.dart';
3 +import 'package:flutter_riverpod/flutter_riverpod.dart';
4 +import 'package:flutter_test/flutter_test.dart';
5 +import 'package:hugeicons/hugeicons.dart';
6 +import 'package:localmind/core/services/app_haptics.dart';
7 +import 'package:localmind/core/theme/app_theme.dart';
8 +import 'package:localmind/features/chat/providers/chat_providers.dart';
9 +import 'package:localmind/features/chat/views/components/background_generation_notice.dart';
10 +import 'package:localmind/features/chat/views/components/heartbeat_dot.dart';
11 +
12 +import 'package:localmind/features/conversations/data/models/conversation.dart';
13 +import 'package:localmind/features/conversations/providers/conversation_providers.dart'
14 + as conv;
15 +import 'package:localmind/l10n/app_localizations.dart';
16 +import 'package:shadcn_ui/shadcn_ui.dart';
17 +
18 +class _FakeChatNotifier extends ChatNotifier {
19 + Conversation? loadedConversation;
20 +
21 + @override
22 + ChatState build() => const ChatState();
23 +
24 + @override
25 + Future<void> loadConversation(Conversation conversation) async {
26 + loadedConversation = conversation;
27 + }
28 +}
29 +
30 +Widget _buildTestApp({
31 + required Widget child,
32 + required List<Conversation> conversations,
33 + _FakeChatNotifier? chatNotifier,
34 + ThemeData? theme,
35 +}) {
36 + return ProviderScope(
37 + overrides: [
38 + appHapticsProvider.overrideWithValue(AppHaptics(() => false)),
39 + conv.conversationsProvider.overrideWith(
40 + () => _StaticConversationsNotifier(conversations),
41 + ),
42 + if (chatNotifier != null) chatProvider.overrideWith(() => chatNotifier),
43 + ],
44 + child: ShadTheme(
45 + data: AppTheme.lightShadTheme,
46 + child: MaterialApp(
47 + theme: theme ?? AppTheme.lightTheme,
48 + locale: const Locale('en'),
49 + localizationsDelegates: const [
50 + AppLocalizations.delegate,
51 + GlobalMaterialLocalizations.delegate,
52 + GlobalWidgetsLocalizations.delegate,
53 + GlobalCupertinoLocalizations.delegate,
54 + ],
55 + supportedLocales: AppLocalizations.supportedLocales,
56 + home: Scaffold(body: Center(child: child)),
57 + ),
58 + ),
59 + );
60 +}
61 +
62 +class _StaticConversationsNotifier extends conv.ConversationsNotifier {
63 + _StaticConversationsNotifier(this._conversations);
64 + final List<Conversation> _conversations;
65 +
66 + @override
67 + Future<List<Conversation>> build() async => _conversations;
68 +}
69 +
70 +void main() {
71 + final sampleConversation = Conversation(
72 + id: 'conv-123',
73 + title: 'Function Creation Guide',
74 + createdAt: DateTime.now(),
75 + updatedAt: DateTime.now(),
76 + );
77 +
78 + testWidgets(
79 + 'shows non-blocking info notice with title for a single remote generation',
80 + (tester) async {
81 + await tester.pumpWidget(
82 + _buildTestApp(
83 + conversations: [sampleConversation],
84 + child: BackgroundGenerationNotice(
85 + generations: const [
86 + ActiveGeneration(conversationId: 'conv-123', isOnDevice: false),
87 + ],
88 + blocksSending: false,
89 + ),
90 + ),
91 + );
92 + await tester.pump();
93 + await tester.pump(const Duration(milliseconds: 300));
94 +
95 + // Verify icon is present
96 + final iconFinder = find.byWidgetPredicate(
97 + (widget) =>
98 + widget is HugeIcon &&
99 + widget.icon == HugeIcons.strokeRoundedBackground,
100 + );
101 + expect(iconFinder, findsOneWidget);
102 +
103 + // Verify circular progress indicator is present
104 + final spinnerFinder = find.byType(CircularProgressIndicator);
105 + expect(spinnerFinder, findsOneWidget);
106 +
107 + // Non-blocking text should mention "in the background"
108 + expect(find.textContaining('background'), findsOneWidget);
109 + expect(find.textContaining('Function Creation Guide'), findsOneWidget);
110 +
111 + // Verify horizontal ordering: Icon (left) < Text (middle) < Spinner (right)
112 + final iconOffset = tester.getCenter(iconFinder);
113 + final textOffset = tester.getCenter(
114 + find.textContaining('Function Creation Guide'),
115 + );
116 + final spinnerOffset = tester.getCenter(spinnerFinder);
117 +
118 + expect(
119 + iconOffset.dx,
120 + lessThan(textOffset.dx),
121 + reason: 'Icon should be to the left of the text',
122 + );
123 + expect(
124 + textOffset.dx,
125 + lessThan(spinnerOffset.dx),
126 + reason:
127 + 'Circular progress indicator should be to the right of the text',
128 + );
129 + },
130 + );
131 +
132 + testWidgets('shows blocking notice with title for on-device conflict', (
133 + tester,
134 + ) async {
135 + await tester.pumpWidget(
136 + _buildTestApp(
137 + conversations: [sampleConversation],
138 + child: BackgroundGenerationNotice(
139 + generations: const [
140 + ActiveGeneration(conversationId: 'conv-123', isOnDevice: true),
141 + ],
142 + blocksSending: true,
143 + ),
144 + ),
145 + );
146 + await tester.pump();
147 + await tester.pump(const Duration(milliseconds: 300));
148 +
149 + // Blocking text should mention "once it finishes"
150 + expect(find.textContaining('once it finishes'), findsOneWidget);
151 + expect(find.textContaining('Function Creation Guide'), findsOneWidget);
152 + });
153 +
154 + testWidgets('shows plural notice when multiple chats are generating', (
155 + tester,
156 + ) async {
157 + await tester.pumpWidget(
158 + _buildTestApp(
159 + conversations: [sampleConversation],
160 + child: const BackgroundGenerationNotice(
161 + generations: [
162 + ActiveGeneration(conversationId: 'conv-123', isOnDevice: false),
163 + ActiveGeneration(conversationId: 'conv-456', isOnDevice: false),
164 + ],
165 + blocksSending: false,
166 + ),
167 + ),
168 + );
169 + await tester.pump();
170 + await tester.pump(const Duration(milliseconds: 300));
171 +
172 + // Should show the plural form with count
173 + expect(find.textContaining('2'), findsOneWidget);
174 + expect(find.textContaining('background'), findsOneWidget);
175 + });
176 +
177 + testWidgets('triggers loadConversation on tap', (tester) async {
178 + final fakeChat = _FakeChatNotifier();
179 +
180 + await tester.pumpWidget(
181 + _buildTestApp(
182 + conversations: [sampleConversation],
183 + chatNotifier: fakeChat,
184 + child: BackgroundGenerationNotice(
185 + generations: const [
186 + ActiveGeneration(conversationId: 'conv-123', isOnDevice: false),
187 + ],
188 + blocksSending: false,
189 + ),
190 + ),
191 + );
192 + await tester.pump();
193 + await tester.pump(const Duration(milliseconds: 300));
194 +
195 + await tester.tap(find.byType(InkWell));
196 + await tester.pump();
197 +
198 + expect(fakeChat.loadedConversation?.id, equals('conv-123'));
199 + expect(
200 + fakeChat.loadedConversation?.title,
201 + equals('Function Creation Guide'),
202 + );
203 + });
204 +
205 + testWidgets('renders untitled fallback when conversation is missing', (
206 + tester,
207 + ) async {
208 + await tester.pumpWidget(
209 + _buildTestApp(
210 + conversations: [],
211 + child: const BackgroundGenerationNotice(
212 + generations: [
213 + ActiveGeneration(conversationId: 'non-existent', isOnDevice: false),
214 + ],
215 + blocksSending: false,
216 + ),
217 + ),
218 + );
219 + await tester.pump();
220 + await tester.pump(const Duration(milliseconds: 300));
221 +
222 + expect(find.byType(CircularProgressIndicator), findsOneWidget);
223 + // The untitled non-blocking text
224 + expect(find.textContaining('background'), findsOneWidget);
225 + });
226 +
227 + testWidgets(
228 + 'clicking notice with 2 chats expands to show the two listed chats and allows selecting one',
229 + (tester) async {
230 + final conv1 = sampleConversation;
231 + final conv2 = Conversation(
232 + id: 'conv-456',
233 + title: 'Second Background Task',
234 + createdAt: DateTime.now(),
235 + updatedAt: DateTime.now(),
236 + );
237 + final fakeChat = _FakeChatNotifier();
238 +
239 + await tester.pumpWidget(
240 + _buildTestApp(
241 + conversations: [conv1, conv2],
242 + chatNotifier: fakeChat,
243 + child: const BackgroundGenerationNotice(
244 + generations: [
245 + ActiveGeneration(conversationId: 'conv-123', isOnDevice: false),
246 + ActiveGeneration(conversationId: 'conv-456', isOnDevice: true),
247 + ],
248 + blocksSending: false,
249 + ),
250 + ),
251 + );
252 + await tester.pump();
253 + await tester.pump(const Duration(milliseconds: 300));
254 +
255 + // Initially collapsed: listed chat items are not visible
256 + expect(find.text('Second Background Task'), findsNothing);
257 +
258 + // Tap the header to expand
259 + await tester.tap(find.textContaining('2'));
260 + await tester.pump();
261 + await tester.pump(const Duration(milliseconds: 300));
262 +
263 + // Now expanded: both chats are listed
264 + expect(find.text('Function Creation Guide'), findsOneWidget);
265 + expect(find.text('Second Background Task'), findsOneWidget);
266 + expect(find.text('On-Device'), findsOneWidget);
267 +
268 + // Only ONE CircularProgressIndicator exists (at the top header)
269 + expect(find.byType(CircularProgressIndicator), findsOneWidget);
270 +
271 + // Each chat item uses a HeartbeatDot instead of a spinner
272 + expect(find.byType(HeartbeatDot), findsNWidgets(2));
273 +
274 + // Tapping the second chat loads it
275 + await tester.tap(find.text('Second Background Task'));
276 + await tester.pump();
277 + await tester.pump(const Duration(milliseconds: 300));
278 +
279 + expect(fakeChat.loadedConversation?.id, equals('conv-456'));
280 + expect(
281 + fakeChat.loadedConversation?.title,
282 + equals('Second Background Task'),
283 + );
284 + },
285 + );
286 +
287 + testWidgets(
288 + 'collapsing when one chat finishes animates without layout overflow',
289 + (tester) async {
290 + final conv1 = sampleConversation;
291 + final conv2 = Conversation(
292 + id: 'conv-456',
293 + title: 'Second Background Task',
294 + createdAt: DateTime.now(),
295 + updatedAt: DateTime.now(),
296 + );
297 +
298 + await tester.pumpWidget(
299 + _buildTestApp(
300 + conversations: [conv1, conv2],
301 + child: const BackgroundGenerationNotice(
302 + generations: [
303 + ActiveGeneration(conversationId: 'conv-123', isOnDevice: false),
304 + ActiveGeneration(conversationId: 'conv-456', isOnDevice: false),
305 + ],
306 + blocksSending: false,
307 + ),
308 + ),
309 + );
310 + await tester.pump();
311 + await tester.pump(const Duration(milliseconds: 300));
312 +
313 + // Expand
314 + await tester.tap(find.textContaining('2'));
315 + await tester.pump();
316 + await tester.pump(const Duration(milliseconds: 300));
317 + expect(find.byType(HeartbeatDot), findsNWidgets(2));
318 +
319 + // Simulate one chat completing (notice drops to 1 generation, triggers collapse)
320 + await tester.pumpWidget(
321 + _buildTestApp(
322 + conversations: [conv1, conv2],
323 + child: const BackgroundGenerationNotice(
324 + generations: [
325 + ActiveGeneration(conversationId: 'conv-123', isOnDevice: false),
326 + ],
327 + blocksSending: false,
328 + ),
329 + ),
330 + );
331 +
332 + // Pump intermediate frames of collapse animation (where subpixel constraints happen)
333 + await tester.pump(const Duration(milliseconds: 30));
334 + await tester.pump(const Duration(milliseconds: 50));
335 + await tester.pump(const Duration(milliseconds: 100));
336 + await tester.pump(const Duration(milliseconds: 200));
337 +
338 + // Fully collapsed to single notice
339 + expect(find.byType(HeartbeatDot), findsNothing);
340 + expect(find.byType(CircularProgressIndicator), findsOneWidget);
341 + expect(tester.takeException(), isNull);
342 + },
343 + );
344 +
345 + testWidgets(
346 + 'tapping header again when expanded collapses the list of chats',
347 + (tester) async {
348 + final conv1 = sampleConversation;
349 + final conv2 = Conversation(
350 + id: 'conv-456',
351 + title: 'Second Background Task',
352 + createdAt: DateTime.now(),
353 + updatedAt: DateTime.now(),
354 + );
355 +
356 + await tester.pumpWidget(
357 + _buildTestApp(
358 + conversations: [conv1, conv2],
359 + child: const BackgroundGenerationNotice(
360 + generations: [
361 + ActiveGeneration(conversationId: 'conv-123', isOnDevice: false),
362 + ActiveGeneration(conversationId: 'conv-456', isOnDevice: false),
363 + ],
364 + blocksSending: false,
365 + ),
366 + ),
367 + );
368 + await tester.pump();
369 + await tester.pump(const Duration(milliseconds: 300));
370 +
371 + // Expand
372 + await tester.tap(find.textContaining('2'));
373 + await tester.pump();
374 + await tester.pump(const Duration(milliseconds: 300));
375 + expect(find.text('Second Background Task'), findsOneWidget);
376 +
377 + // Tap header again to collapse. The spring needs longer than the
378 + // expand check to fall below the visibility threshold.
379 + await tester.tap(find.textContaining('2'));
380 + await tester.pump();
381 + await tester.pump(const Duration(seconds: 2));
382 + expect(find.text('Second Background Task'), findsNothing);
383 + },
384 + );
385 +
386 + testWidgets(
387 + 'blocked send with several chats opens the on-device chat on tap',
388 + (tester) async {
389 + final onDeviceConversation = Conversation(
390 + id: 'conv-456',
391 + title: 'Local Gemma Chat',
392 + createdAt: DateTime.now(),
393 + updatedAt: DateTime.now(),
394 + );
395 + final fakeChat = _FakeChatNotifier();
396 +
397 + await tester.pumpWidget(
398 + _buildTestApp(
399 + conversations: [sampleConversation, onDeviceConversation],
400 + chatNotifier: fakeChat,
401 + child: const BackgroundGenerationNotice(
402 + generations: [
403 + ActiveGeneration(conversationId: 'conv-123', isOnDevice: false),
404 + ActiveGeneration(conversationId: 'conv-456', isOnDevice: true),
405 + ],
406 + blocksSending: true,
407 + ),
408 + ),
409 + );
410 + await tester.pump();
411 + await tester.pump(const Duration(milliseconds: 300));
412 +
413 + expect(find.textContaining('Local Gemma Chat'), findsOneWidget);
414 + expect(find.textContaining('once it finishes'), findsOneWidget);
415 +
416 + await tester.tap(find.byType(InkWell));
417 + await tester.pump();
418 + await tester.pump(const Duration(milliseconds: 300));
419 +
420 + // Opens the blocking chat instead of expanding a list.
421 + expect(fakeChat.loadedConversation?.id, equals('conv-456'));
422 + expect(find.byType(HeartbeatDot), findsNothing);
423 + },
424 + );
425 +
426 + testWidgets('listed chat without a title uses a short fallback name', (
427 + tester,
428 + ) async {
429 + await tester.pumpWidget(
430 + _buildTestApp(
431 + conversations: [sampleConversation],
432 + child: const BackgroundGenerationNotice(
433 + generations: [
434 + ActiveGeneration(conversationId: 'conv-123', isOnDevice: false),
435 + ActiveGeneration(conversationId: 'missing', isOnDevice: false),
436 + ],
437 + blocksSending: false,
438 + ),
439 + ),
440 + );
441 + await tester.pump();
442 + await tester.pump(const Duration(milliseconds: 300));
443 +
444 + await tester.tap(find.textContaining('2'));
445 + await tester.pump();
446 + await tester.pump(const Duration(milliseconds: 300));
447 +
448 + expect(find.text('Untitled chat'), findsOneWidget);
449 + });
450 +}
test/features/chat/heartbeat_dot_test.dart new
+51
@@ -0,0 +1,51 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_test/flutter_test.dart';
3 +import 'package:localmind/features/chat/views/components/heartbeat_dot.dart';
4 +
5 +void main() {
6 + group('HeartbeatDot', () {
7 + testWidgets('renders with default size and primary color', (tester) async {
8 + await tester.pumpWidget(
9 + const MaterialApp(
10 + home: Scaffold(body: Center(child: HeartbeatDot())),
11 + ),
12 + );
13 +
14 + expect(find.byType(HeartbeatDot), findsOneWidget);
15 + // Ensure initial frame renders
16 + await tester.pump();
17 + expect(tester.takeException(), isNull);
18 + });
19 +
20 + testWidgets('renders with custom size and custom color', (tester) async {
21 + const customColor = Colors.teal;
22 + await tester.pumpWidget(
23 + const MaterialApp(
24 + home: Scaffold(
25 + body: Center(child: HeartbeatDot(size: 10, color: customColor)),
26 + ),
27 + ),
28 + );
29 +
30 + expect(find.byType(HeartbeatDot), findsOneWidget);
31 + await tester.pump();
32 + expect(tester.takeException(), isNull);
33 + });
34 +
35 + testWidgets('animates heartbeat cycle smoothly over time', (tester) async {
36 + await tester.pumpWidget(
37 + const MaterialApp(
38 + home: Scaffold(body: Center(child: HeartbeatDot(size: 7))),
39 + ),
40 + );
41 +
42 + // Advance through heartbeat phases (first beat, recoil, second beat, rest)
43 + await tester.pump(const Duration(milliseconds: 200));
44 + await tester.pump(const Duration(milliseconds: 300));
45 + await tester.pump(const Duration(milliseconds: 400));
46 + await tester.pump(const Duration(milliseconds: 500));
47 +
48 + expect(tester.takeException(), isNull);
49 + });
50 + });
51 +}
test/features/chat/providers/chat_background_generation_test.dart
+256 -17
@@ -17,6 +17,7 @@ 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 +
21 import 'package:localmind/features/chat/providers/tooling_providers.dart';
22 import 'package:localmind/features/conversations/data/models/conversation.dart';
23 import 'package:localmind/features/conversations/providers/conversation_providers.dart'
@@ -31,6 +32,7 @@ void main() {
32 TestWidgetsFlutterBinding.ensureInitialized();
33
34 late _ControllableChatService chatService;
35 + late _TestChatBackgroundService bgService;
36 late ProviderContainer container;
37 late _BackgroundTestChatNotifier notifier;
38
@@ -38,13 +40,14 @@ void main() {
40 SharedPreferences.setMockInitialValues({});
41 final prefs = await SharedPreferences.getInstance();
42 chatService = _ControllableChatService();
43 + bgService = _TestChatBackgroundService();
44 container = ProviderContainer(
45 overrides: [
46 sharedPreferencesProvider.overrideWithValue(prefs),
47 activeServerProvider.overrideWith(_RemoteServerNotifier.new),
48 activeChatTargetProvider.overrideWithValue(
49 ActiveChatTarget(
47 - server: _server,
50 + server: _remoteServer,
51 selectedModel: null,
52 effectiveModelId: 'model',
53 modelLabel: 'Model',
@@ -52,14 +55,13 @@ void main() {
55 ),
56 chatProvider.overrideWith(_BackgroundTestChatNotifier.new),
57 chatServiceProvider.overrideWithValue(chatService),
58 + chatServiceFactoryProvider.overrideWithValue((_) => chatService),
59 chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
60 chatMcpConfigProvider.overrideWith(_DisabledMcpNotifier.new),
61 toolRegistryProvider.overrideWithValue(
62 ToolRegistry(providers: const []),
63 ),
60 - chatBackgroundServiceProvider.overrideWithValue(
61 - _TestChatBackgroundService(),
62 - ),
64 + chatBackgroundServiceProvider.overrideWithValue(bgService),
65 settingsProvider.overrideWith(_TestSettingsNotifier.new),
66 voiceModeProvider.overrideWith(_IdleVoiceModeNotifier.new),
67 conv.conversationsProvider.overrideWith(_FakeConversationsNotifier.new),
@@ -90,8 +92,11 @@ void main() {
92 expect(chatService.cancelCount, 0);
93 expect(container.read(chatProvider).isStreaming, isFalse);
94 expect(container.read(chatProvider).messages.first.id, 'b-user');
93 - expect(container.read(streamingConversationIdProvider), 'conversation-a');
94 - expect(container.read(isStreamingProvider), isTrue);
95 + // The generation for conversation-a should still be tracked.
96 + final generations = container.read(activeGenerationsProvider);
97 + expect(generations, contains('conversation-a'));
98 + // isStreamingProvider is about the *open* chat (B), which is not streaming.
99 + expect(container.read(isStreamingProvider), isFalse);
100
101 chatService.emit(' world');
102 await chatService.finish();
@@ -103,7 +108,7 @@ void main() {
108 expect(saved.status, MessageStatus.complete);
109 // Chat B's view is untouched by the background completion.
110 expect(container.read(chatProvider).messages.first.id, 'b-user');
106 - expect(container.read(streamingConversationIdProvider), isNull);
111 + expect(container.read(activeGenerationsProvider), isEmpty);
112 expect(container.read(isStreamingProvider), isFalse);
113 });
114
@@ -133,18 +138,20 @@ void main() {
138 },
139 );
140
136 - test('sending in another chat is blocked while a reply runs', () async {
141 + test('remote: sending in another chat is allowed (not blocked)', () async {
142 await startReplyInA();
143 await notifier.loadConversation(_conversationB);
144
145 + // With a remote server, sending in chat B should NOT be blocked.
146 await notifier.sendMessage('new question');
147
148 + // Should not set the on-device busy error.
149 expect(
150 container.read(chatProvider).errorMessage,
144 - generatingElsewhereMessage,
151 + isNot(onDeviceBusyMessage),
152 );
146 - expect(chatService.controllers, hasLength(1));
147 - expect(chatService.cancelCount, 0);
153 + // Both streams run concurrently.
154 + expect(chatService.controllers, hasLength(2));
155 });
156
157 test('a second switch does not cancel the background reply', () async {
@@ -154,7 +161,8 @@ void main() {
161 await _pump();
162
163 expect(chatService.cancelCount, 0);
157 - expect(container.read(streamingConversationIdProvider), 'conversation-a');
164 + final generations = container.read(activeGenerationsProvider);
165 + expect(generations, contains('conversation-a'));
166 });
167
168 test('cancelGenerationFor stops a background reply', () async {
@@ -164,9 +172,222 @@ void main() {
172 await notifier.cancelGenerationFor('conversation-a');
173
174 expect(chatService.cancelCount, 1);
167 - expect(container.read(streamingConversationIdProvider), isNull);
175 + expect(container.read(activeGenerationsProvider), isEmpty);
176 expect(container.read(isStreamingProvider), isFalse);
177 });
178 +
179 + test('stopping one reply cancels only that service, not the other', () async {
180 + // Create two controllable services for two concurrent streams.
181 + final serviceA = _ControllableChatService();
182 + final serviceB = _ControllableChatService();
183 + var factoryCallCount = 0;
184 + container = ProviderContainer(
185 + overrides: [
186 + sharedPreferencesProvider.overrideWithValue(
187 + await SharedPreferences.getInstance(),
188 + ),
189 + activeServerProvider.overrideWith(_RemoteServerNotifier.new),
190 + activeChatTargetProvider.overrideWithValue(
191 + ActiveChatTarget(
192 + server: _remoteServer,
193 + selectedModel: null,
194 + effectiveModelId: 'model',
195 + modelLabel: 'Model',
196 + ),
197 + ),
198 + chatProvider.overrideWith(_BackgroundTestChatNotifier.new),
199 + chatServiceProvider.overrideWithValue(serviceA),
200 + chatServiceFactoryProvider.overrideWithValue((_) {
201 + factoryCallCount++;
202 + return factoryCallCount == 1 ? serviceA : serviceB;
203 + }),
204 + chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
205 + chatMcpConfigProvider.overrideWith(_DisabledMcpNotifier.new),
206 + toolRegistryProvider.overrideWithValue(
207 + ToolRegistry(providers: const []),
208 + ),
209 + chatBackgroundServiceProvider.overrideWithValue(bgService),
210 + settingsProvider.overrideWith(_TestSettingsNotifier.new),
211 + voiceModeProvider.overrideWith(_IdleVoiceModeNotifier.new),
212 + conv.conversationsProvider.overrideWith(_FakeConversationsNotifier.new),
213 + ],
214 + );
215 + await container.read(conv.conversationsProvider.future);
216 + final n =
217 + container.read(chatProvider.notifier) as _BackgroundTestChatNotifier;
218 +
219 + // Start reply in A, switch to B, send.
220 + await n.loadConversation(_conversationA);
221 + await n.continueFromMessage('a-assistant');
222 + await _pump();
223 + serviceA.emit('Hello from A');
224 + await _pump();
225 +
226 + await n.loadConversation(_conversationB);
227 + await n.continueFromMessage('b-assistant');
228 + await _pump();
229 + serviceB.emit('Hello from B');
230 + await _pump();
231 +
232 + expect(container.read(activeGenerationsProvider).length, 2);
233 +
234 + // Cancel only B (the open chat).
235 + await n.cancelStream();
236 + await _pump();
237 +
238 + expect(serviceB.cancelCount, 1, reason: 'B should be cancelled');
239 + expect(serviceA.cancelCount, 0, reason: 'A should still run');
240 + expect(container.read(activeGenerationsProvider).length, 1);
241 + expect(
242 + container.read(activeGenerationsProvider),
243 + contains('conversation-a'),
244 + );
245 + });
246 +
247 + ProviderContainer buildContainer({
248 + required Server server,
249 + required ChatService Function(Server) factory,
250 + }) {
251 + final c = ProviderContainer(
252 + overrides: [
253 + sharedPreferencesProvider.overrideWithValue(
254 + container.read(sharedPreferencesProvider),
255 + ),
256 + activeServerProvider.overrideWith(_RemoteServerNotifier.new),
257 + activeChatTargetProvider.overrideWithValue(
258 + ActiveChatTarget(
259 + server: server,
260 + selectedModel: null,
261 + effectiveModelId: 'model',
262 + modelLabel: 'Model',
263 + ),
264 + ),
265 + chatProvider.overrideWith(_BackgroundTestChatNotifier.new),
266 + chatServiceProvider.overrideWithValue(chatService),
267 + chatServiceFactoryProvider.overrideWithValue(factory),
268 + chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
269 + chatMcpConfigProvider.overrideWith(_DisabledMcpNotifier.new),
270 + toolRegistryProvider.overrideWithValue(
271 + ToolRegistry(providers: const []),
272 + ),
273 + chatBackgroundServiceProvider.overrideWithValue(bgService),
274 + settingsProvider.overrideWith(_TestSettingsNotifier.new),
275 + voiceModeProvider.overrideWith(_IdleVoiceModeNotifier.new),
276 + conv.conversationsProvider.overrideWith(_FakeConversationsNotifier.new),
277 + ],
278 + );
279 + addTearDown(c.dispose);
280 + return c;
281 + }
282 +
283 + test('concurrent remote replies stream into their own chats', () async {
284 + final serviceA = _ControllableChatService();
285 + final serviceB = _ControllableChatService();
286 + var built = 0;
287 + final c = buildContainer(
288 + server: _remoteServer,
289 + factory: (_) => ++built == 1 ? serviceA : serviceB,
290 + );
291 + await c.read(conv.conversationsProvider.future);
292 + final n = c.read(chatProvider.notifier) as _BackgroundTestChatNotifier;
293 +
294 + await n.loadConversation(_conversationA);
295 + await n.continueFromMessage('a-assistant');
296 + await _pump();
297 + serviceA.emit(' one');
298 +
299 + await n.loadConversation(_conversationB);
300 + await n.continueFromMessage('b-assistant');
301 + await _pump();
302 + serviceB.emit(' two');
303 + await _pump();
304 +
305 + expect(c.read(activeGenerationsProvider).keys, [
306 + 'conversation-a',
307 + 'conversation-b',
308 + ]);
309 + expect(bgService.startCount, 1);
310 +
311 + // A finishes while B is the open chat; B keeps streaming.
312 + await serviceA.finish();
313 + await _pump();
314 + expect(c.read(activeGenerationsProvider).keys, ['conversation-b']);
315 + expect(c.read(chatProvider).isStreaming, isTrue);
316 + expect(bgService.stopCount, 0);
317 +
318 + await serviceB.finish();
319 + await _pump();
320 +
321 + final a = n.saved.lastWhere((m) => m.id == 'a-assistant');
322 + final b = n.saved.lastWhere((m) => m.id == 'b-assistant');
323 + expect(a.conversationId, 'conversation-a');
324 + expect(a.content, contains('one'));
325 + expect(a.content, isNot(contains('two')));
326 + expect(b.conversationId, 'conversation-b');
327 + expect(b.content, contains('two'));
328 + expect(b.content, isNot(contains('one')));
329 + expect(c.read(activeGenerationsProvider), isEmpty);
330 + expect(bgService.stopCount, 1);
331 + });
332 +
333 + test('on-device: a second chat cannot send while one is replying', () async {
334 + final onDeviceService = _ControllableChatService();
335 + final c = buildContainer(
336 + server: _onDeviceServer,
337 + factory: (_) => onDeviceService,
338 + );
339 + await c.read(conv.conversationsProvider.future);
340 + final n = c.read(chatProvider.notifier) as _BackgroundTestChatNotifier;
341 +
342 + await n.loadConversation(_conversationA);
343 + await n.continueFromMessage('a-assistant');
344 + await _pump();
345 + onDeviceService.emit(' hi');
346 + await n.loadConversation(_conversationB);
347 + await _pump();
348 +
349 + await n.sendMessage('blocked question');
350 +
351 + expect(c.read(chatProvider).errorMessage, onDeviceBusyMessage);
352 + expect(onDeviceService.controllers, hasLength(1));
353 + expect(
354 + c.read(activeGenerationsProvider).keys,
355 + ['conversation-a'],
356 + reason: 'the on-device reply in A keeps running',
357 + );
358 + expect(onDeviceService.cancelCount, 0);
359 +
360 + // Once it finishes, the other chat may send.
361 + await onDeviceService.finish();
362 + await _pump();
363 + await n.sendMessage('now allowed');
364 + expect(c.read(chatProvider).errorMessage, isNot(onDeviceBusyMessage));
365 + expect(onDeviceService.controllers, hasLength(2));
366 + });
367 +
368 + test('cancelAllGenerations clears every session', () async {
369 + await startReplyInA();
370 +
371 + await notifier.cancelAllGenerations();
372 +
373 + expect(chatService.cancelCount, 1);
374 + expect(container.read(activeGenerationsProvider), isEmpty);
375 + });
376 +
377 + test(
378 + 'background service starts once and stops after the last session ends',
379 + () async {
380 + await startReplyInA();
381 + expect(bgService.startCount, 1);
382 + expect(bgService.stopCount, 0);
383 +
384 + await chatService.finish();
385 + await _pump();
386 +
387 + expect(bgService.startCount, 1);
388 + expect(bgService.stopCount, 1);
389 + },
390 + );
391 }
392
393 Future<void> _pump() async {
@@ -175,7 +396,7 @@ Future<void> _pump() async {
396 }
397 }
398
178 -final _server = Server(
399 +final _remoteServer = Server(
400 id: 'remote',
401 name: 'Remote',
402 type: ServerType.ollama,
@@ -186,6 +407,17 @@ final _server = Server(
407 status: ConnectionStatus.connected,
408 );
409
410 +final _onDeviceServer = Server(
411 + id: 'on-device',
412 + name: 'On-device',
413 + type: ServerType.onDevice,
414 + host: 'localhost',
415 + port: 0,
416 + createdAt: DateTime.utc(2026, 9, 28),
417 + lastConnectedAt: DateTime.utc(2026, 9, 28),
418 + status: ConnectionStatus.connected,
419 +);
420 +
421 final _conversationA = Conversation(
422 id: 'conversation-a',
423 title: 'Chat A',
@@ -319,7 +551,7 @@ class _FakeConversationsNotifier extends conv.ConversationsNotifier {
551
552 class _RemoteServerNotifier extends ActiveServerNotifier {
553 @override
322 - Server? build() => _server;
554 + Server? build() => _remoteServer;
555 }
556
557 class _DisabledMcpNotifier extends ChatMcpConfigNotifier {
@@ -328,11 +560,18 @@ class _DisabledMcpNotifier extends ChatMcpConfigNotifier {
560 }
561
562 class _TestChatBackgroundService extends ChatBackgroundService {
563 + int startCount = 0;
564 + int stopCount = 0;
565 +
566 @override
332 - Future<void> start() async {}
567 + Future<void> start() async {
568 + startCount++;
569 + }
570
571 @override
335 - Future<void> stop() async {}
572 + Future<void> stop() async {
573 + stopCount++;
574 + }
575 }
576
577 class _TestSettingsNotifier extends SettingsNotifier {