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 {