@tduncan / Cortana / commits / 2d7b8ca

refactor: consolidate model resolution into ActiveChatTarget provider and add validation to prevent chat interactions without a selected model

abdulmominsakib committed Aug 13, 2026 at 12:18 UTC 2d7b8ca6c76d9962fd0d798000b3db8c1c2437a8
18 files changed +1044 -297
lib/features/chat/data/smart_reply_service.dart
+6 -8
@@ -9,6 +9,11 @@ import 'models/chat_parameters.dart';
9 import 'models/message.dart';
10
11 class SmartReplyService {
12 + static final RegExp _greetingPattern = RegExp(
13 + r'\b(?:hello|hi|hey|greetings|good morning|good afternoon|good evening|welcome)\b',
14 + caseSensitive: false,
15 + );
16 +
17 final Map<String, Future<List<String>>> _inFlightSuggestions = {};
18
19 Future<List<String>> suggestRepliesWithLLM({
@@ -304,14 +309,7 @@ class SmartReplyService {
309 final lowerContent = cleanContent.toLowerCase();
310
311 // 1. Greetings & Welcomes
307 - if (lowerContent.contains('hello') ||
308 - lowerContent.contains('hi') ||
309 - lowerContent.contains('hey') ||
310 - lowerContent.contains('greetings') ||
311 - lowerContent.contains('good morning') ||
312 - lowerContent.contains('good afternoon') ||
313 - lowerContent.contains('good evening') ||
314 - lowerContent.contains('welcome')) {
312 + if (_greetingPattern.hasMatch(cleanContent)) {
313 return [
314 'Hello! How are you?',
315 'I have a question',
lib/features/chat/providers/chat_notifier.dart
+21 -13
@@ -735,20 +735,28 @@ class ChatNotifier extends Notifier<ChatState> {
735 }
736
737 Future<void> sendMessage(String content, {List<File>? attachments}) async {
738 - final server = ref.read(activeServerProvider);
739 - final selectedModel = ref.read(selectedModelProvider);
740 - final chatService = ref.read(chatServiceProvider);
738 + final target = ref.read(activeChatTargetProvider);
739 + final server = target.server;
740 + final selectedModel = target.selectedModel;
741 + final effectiveModelId = target.effectiveModelId;
742
743 if (server == null) {
744 state = state.copyWith(errorMessage: 'No server connected');
745 return;
746 }
747
748 + if (effectiveModelId == null) {
749 + state = state.copyWith(errorMessage: modelSelectionRequiredMessage);
750 + return;
751 + }
752 +
753 if (content.trim().isEmpty &&
754 (attachments == null || attachments.isEmpty)) {
755 return;
756 }
757
758 + final chatService = ref.read(chatServiceProvider);
759 +
760 await _abortStreamImmediately();
761
762 // Stop the STT listener so the mic doesn't stay red while the model
@@ -827,7 +835,7 @@ class ChatNotifier extends Notifier<ChatState> {
835 content: '',
836 createdAt: DateTime.now(),
837 status: MessageStatus.streaming,
830 - modelId: selectedModel?.id,
838 + modelId: effectiveModelId,
839 variantGroupId: assistantGroupId,
840 variantIndex: 0,
841 threadOrder: assistantThreadOrder,
@@ -922,7 +930,7 @@ class ChatNotifier extends Notifier<ChatState> {
930 _streamSubscription = chatService
931 .sendMessage(
932 server: server,
925 - modelId: selectedModel?.id ?? 'default',
933 + modelId: effectiveModelId,
934 messages: messagesForApi,
935 params: chatParams,
936 integrations: integrations,
@@ -1618,7 +1626,8 @@ class ChatNotifier extends Notifier<ChatState> {
1626 }
1627
1628 String? _resolveModelIdForTitle(Conversation? conversation) {
1621 - return conversation?.modelId ?? ref.read(selectedModelProvider)?.id;
1629 + return conversation?.modelId ??
1630 + ref.read(activeChatTargetProvider).effectiveModelId;
1631 }
1632
1633 ChatService? _resolveChatServiceForTitle(Server server) {
@@ -1658,9 +1667,7 @@ class ChatNotifier extends Notifier<ChatState> {
1667 }) {
1668 final modelId =
1669 selectedModel?.id ??
1661 - (server.type == ServerType.onDevice
1662 - ? ref.read(onDeviceEngineProvider).loadedModelId
1663 - : null);
1670 + ref.read(activeChatTargetProvider).effectiveModelId;
1671
1672 unawaited(
1673 ref
@@ -2360,17 +2367,18 @@ class ChatNotifier extends Notifier<ChatState> {
2367 if (state.isStreaming) return;
2368 if (state.messages.isEmpty) return;
2369
2363 - final server = ref.read(activeServerProvider);
2364 - final selectedModel = ref.read(selectedModelProvider);
2370 + final target = ref.read(activeChatTargetProvider);
2371 + final server = target.server;
2372 + final modelId = target.effectiveModelId;
2373 final chatService = ref.read(chatServiceProvider);
2366 - if (server == null || chatService == null || selectedModel == null) return;
2374 + if (server == null || chatService == null || modelId == null) return;
2375
2376 final generated = await ref
2377 .read(smartReplyServiceProvider)
2378 .generateUserMessage(
2379 chatService: chatService,
2380 server: server,
2373 - modelId: selectedModel.id,
2381 + modelId: modelId,
2382 messages: state.messages,
2383 params: ref.read(chatParamsProvider),
2384 );
lib/features/chat/providers/model_selection_providers.dart
+77 -2
@@ -9,14 +9,89 @@ import 'package:localmind/features/chat/providers/chat_reasoning_providers.dart'
9 import 'package:localmind/features/chat/providers/model_loading_providers.dart';
10 import 'package:localmind/features/models/data/models/model_info.dart';
11 import 'package:localmind/features/models/utils/model_instance_utils.dart';
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
17 +const modelSelectionRequiredMessage = 'You need to select a model first';
18 +
19 +class ActiveChatTarget {
20 + final Server? server;
21 + final ModelInfo? selectedModel;
22 + final String? effectiveModelId;
23 + final String modelLabel;
24 +
25 + const ActiveChatTarget({
26 + required this.server,
27 + required this.selectedModel,
28 + required this.effectiveModelId,
29 + required this.modelLabel,
30 + });
31 +
32 + bool get isReady => server != null && effectiveModelId != null;
33 +}
34 +
35 +/// The server/model pair that a message will actually use.
36 +///
37 +/// A loaded on-device model remains usable even before the model picker has
38 +/// recreated its [ModelInfo]. A native restored spec is deliberately excluded:
39 +/// it is metadata for lazy loading, not proof that a model is in memory.
40 +/// Remote servers retain their existing `default` model routing when no
41 +/// explicit model is selected.
42 +final activeChatTargetProvider = Provider<ActiveChatTarget>((ref) {
43 + final server = ref.watch(activeServerProvider);
44 + if (server == null) {
45 + return const ActiveChatTarget(
46 + server: null,
47 + selectedModel: null,
48 + effectiveModelId: null,
49 + modelLabel: 'No model',
50 + );
51 + }
52 +
53 + final selected = ref.watch(selectedModelProvider);
54 + final selectedForServer = selected?.serverId == server.id ? selected : null;
55 + if (selectedForServer != null) {
56 + return ActiveChatTarget(
57 + server: server,
58 + selectedModel: selectedForServer,
59 + effectiveModelId: selectedForServer.id,
60 + modelLabel: selectedForServer.displayName,
61 + );
62 + }
63 +
64 + if (server.type != ServerType.onDevice) {
65 + return ActiveChatTarget(
66 + server: server,
67 + selectedModel: null,
68 + effectiveModelId: 'default',
69 + modelLabel: 'Default model',
70 + );
71 + }
72 +
73 + final engine = ref.watch(onDeviceEngineProvider);
74 + final modelId = engine.status == OnDeviceEngineStatus.loaded
75 + ? engine.loadedModelId
76 + : null;
77 + final model = modelId == null
78 + ? null
79 + : OnDeviceModel.curatedModels
80 + .where((candidate) => candidate.id == modelId)
81 + .firstOrNull;
82 +
83 + return ActiveChatTarget(
84 + server: server,
85 + selectedModel: null,
86 + effectiveModelId: modelId,
87 + modelLabel: model?.name ?? modelId ?? 'No model',
88 + );
89 +});
90 +
91 final selectedModelProvider =
92 NotifierProvider<SelectedModelNotifier, ModelInfo?>(() {
18 - return SelectedModelNotifier();
19 -});
93 + return SelectedModelNotifier();
94 + });
95
96 class SelectedModelNotifier extends Notifier<ModelInfo?> {
97 @override
lib/features/chat/providers/smart_reply_providers.dart
+5 -5
@@ -5,7 +5,6 @@ import 'package:localmind/core/models/enums.dart';
5 import 'package:localmind/core/providers/app_providers.dart';
6 import 'package:localmind/features/conversations/providers/conversation_providers.dart'
7 as conv;
8 -import 'package:localmind/features/servers/providers/server_providers.dart';
8 import 'package:localmind/features/voice_mode/providers/voice_mode_provider.dart';
9 import 'chat_notifier.dart';
10 import 'chat_params_providers.dart';
@@ -40,17 +39,18 @@ final smartRepliesProvider = FutureProvider<List<String>>((ref) async {
39 List<String> suggestions = [];
40
41 if (settings.smartReplyEnabled) {
43 - final server = ref.watch(activeServerProvider);
44 - final selectedModel = ref.watch(selectedModelProvider);
42 + final target = ref.watch(activeChatTargetProvider);
43 + final server = target.server;
44 + final modelId = target.effectiveModelId;
45 final chatParams = ref.watch(chatParamsProvider);
46 final chatService = ref.watch(chatServiceProvider);
47
48 - if (server != null && chatService != null && selectedModel != null) {
48 + if (server != null && chatService != null && modelId != null) {
49 final service = ref.read(smartReplyServiceProvider);
50 suggestions = await service.suggestRepliesWithLLM(
51 chatService: chatService,
52 server: server,
53 - modelId: selectedModel.id,
53 + modelId: modelId,
54 messages: messages,
55 params: chatParams,
56 personaSystemPrompt: settings.smartRepliesUsePersona
lib/features/chat/views/components/chat_bottom_bar.dart
+6 -5
@@ -5,7 +5,8 @@ import 'package:localmind/core/providers/app_providers.dart';
5 import 'package:localmind/features/chat/views/components/chat_input_bar.dart';
6 import 'package:localmind/features/chat/views/components/top_bar/smart_reply_chips.dart';
7 import 'package:localmind/features/chat/providers/chat_providers.dart';
8 -import 'package:localmind/features/conversations/providers/conversation_providers.dart' as conv;
8 +import 'package:localmind/features/conversations/providers/conversation_providers.dart'
9 + as conv;
10 import 'package:localmind/features/models/views/model_picker_sheet.dart';
11 import 'package:localmind/l10n/app_localizations.dart';
12
@@ -109,11 +110,11 @@ class SmartReplyChipsWrapper extends ConsumerWidget {
110 Widget build(BuildContext context, WidgetRef ref) {
111 return SmartReplyChips(
112 onSend: (message) {
112 - if (ref.read(selectedModelProvider) == null) {
113 + if (!ref.read(activeChatTargetProvider).isReady) {
114 final l10n = AppLocalizations.of(context)!;
114 - ScaffoldMessenger.of(context).showSnackBar(
115 - SnackBar(content: Text(l10n.model_required_toast)),
116 - );
115 + ScaffoldMessenger.of(
116 + context,
117 + ).showSnackBar(SnackBar(content: Text(l10n.model_required_toast)));
118 showModalBottomSheet(
119 context: context,
120 isScrollControlled: true,
lib/features/chat/views/components/chat_input_bar.dart
+7 -18
@@ -25,7 +25,6 @@ import '../../../models/components/thinking_mode_chip.dart';
25 import 'attach_sheet.dart';
26 import 'image_preview_dialog.dart';
27 import 'model_chip.dart';
28 -import '../../../on_device/providers/on_device_providers.dart';
28 import '../../../voice_mode/views/voice_mode_overlay.dart';
29
30 class ChatInputBar extends ConsumerStatefulWidget {
@@ -363,16 +362,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
362
363 Widget _buildVoiceModeButton(ThemeData theme) {
364 final isDark = theme.brightness == Brightness.dark;
366 - final onDeviceLoaded = ref.watch(
367 - onDeviceEngineProvider.select(
368 - (s) => s.status == OnDeviceEngineStatus.loaded,
369 - ),
370 - );
371 - final connectionStatus = ref.watch(connectionStatusProvider);
372 - final isConnected = connectionStatus == ConnectionStatus.connected;
373 - final hasRemoteModel =
374 - isConnected && ref.watch(selectedModelProvider) != null;
375 - final modelReady = onDeviceLoaded || hasRemoteModel;
365 + final modelReady = ref.watch(activeChatTargetProvider).isReady;
366 final l10n = AppLocalizations.of(context)!;
367
368 return Container(
@@ -443,7 +433,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
433
434 if (text.isEmpty && _attachedFiles.isEmpty) return;
435
446 - if (!_ensureModelSelected()) return;
436 + if (!_ensureChatTarget()) return;
437
438 ref.read(appHapticsProvider).medium();
439
@@ -471,7 +461,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
461 final text = _controller.text.trim();
462 if (text.isEmpty && _attachedFiles.isEmpty) return;
463
474 - if (!_ensureModelSelected()) return;
464 + if (!_ensureChatTarget()) return;
465
466 ref.read(appHapticsProvider).medium();
467 ref
@@ -489,11 +479,10 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
479 });
480 }
481
492 - /// Returns true if a model is currently selected. When no model is selected,
493 - /// shows a SnackBar prompting the user to pick one and opens the model
494 - /// picker sheet, then returns false so the caller can bail out.
495 - bool _ensureModelSelected() {
496 - if (ref.read(selectedModelProvider) != null) return true;
482 + /// Returns true when the active server has a usable model target. When it
483 + /// does not, prompts the user to choose an on-device model.
484 + bool _ensureChatTarget() {
485 + if (ref.read(activeChatTargetProvider).isReady) return true;
486 final l10n = AppLocalizations.of(context)!;
487 ScaffoldMessenger.of(
488 context,
lib/features/chat/views/components/message_list/message_area.dart
+3 -2
@@ -7,7 +7,8 @@ import 'package:localmind/core/services/share_service.dart';
7 import 'package:localmind/features/chat/data/models/message.dart';
8 import 'package:localmind/features/chat/providers/chat_providers.dart';
9 import 'package:localmind/features/conversations/data/models/conversation.dart';
10 -import 'package:localmind/features/conversations/providers/conversation_providers.dart' as conv;
10 +import 'package:localmind/features/conversations/providers/conversation_providers.dart'
11 + as conv;
12 import 'package:localmind/features/models/views/model_picker_sheet.dart';
13 import 'package:localmind/features/personas/providers/personas_providers.dart';
14 import 'package:localmind/features/chat/views/components/model_info_sheet.dart';
@@ -70,7 +71,7 @@ class MessageArea extends ConsumerWidget {
71 return EmptyState(
72 isCloudProvider: isCloudProvider,
73 onQuickPrompt: (prompt) {
73 - if (ref.read(selectedModelProvider) == null) {
74 + if (!ref.read(activeChatTargetProvider).isReady) {
75 final toastL10n = AppLocalizations.of(context)!;
76 ScaffoldMessenger.of(context).showSnackBar(
77 SnackBar(content: Text(toastL10n.model_required_toast)),
lib/features/chat/views/components/top_bar/screen_app_bar.dart
+179 -169
@@ -51,48 +51,51 @@ class ScreenAppBar extends ConsumerWidget {
51 top: true,
52 bottom: false,
53 child: Container(
54 - height: 56,
55 - padding: const EdgeInsets.symmetric(horizontal: 4),
56 - child: Row(
57 - children: [
58 - IconButton(
59 - icon: const HugeIcon(icon: HugeIcons.strokeRoundedCancel01),
60 - onPressed: () =>
61 - ref.read(messageSelectionModeProvider.notifier).disable(),
62 - ),
63 - Expanded(
64 - child: Text(
65 - l10n.selected_count(selectedMessageIds.length),
66 - style: const TextStyle(
67 - fontSize: 16,
68 - fontWeight: FontWeight.w600,
54 + height: 56,
55 + padding: const EdgeInsets.symmetric(horizontal: 4),
56 + child: Row(
57 + children: [
58 + IconButton(
59 + icon: const HugeIcon(icon: HugeIcons.strokeRoundedCancel01),
60 + onPressed: () =>
61 + ref.read(messageSelectionModeProvider.notifier).disable(),
62 + ),
63 + Expanded(
64 + child: Text(
65 + l10n.selected_count(selectedMessageIds.length),
66 + style: const TextStyle(
67 + fontSize: 16,
68 + fontWeight: FontWeight.w600,
69 + ),
70 ),
71 ),
71 - ),
72 - IconButton(
73 - icon: const HugeIcon(icon: HugeIcons.strokeRoundedShare01),
74 - tooltip: l10n.export_conversation,
75 - onPressed: selectedMessageIds.isEmpty
76 - ? null
77 - : () => _shareSelectedMessages(
78 - context,
79 - ref,
80 - selectedMessageIds,
81 - ),
82 - ),
83 - IconButton(
84 - icon: const HugeIcon(icon: HugeIcons.strokeRoundedDelete01, color: Colors.red),
85 - tooltip: l10n.delete,
86 - onPressed: selectedMessageIds.isEmpty
87 - ? null
88 - : () => _deleteSelectedMessages(
89 - context,
90 - ref,
91 - selectedMessageIds,
92 - ),
93 - ),
94 - ],
95 - ),
72 + IconButton(
73 + icon: const HugeIcon(icon: HugeIcons.strokeRoundedShare01),
74 + tooltip: l10n.export_conversation,
75 + onPressed: selectedMessageIds.isEmpty
76 + ? null
77 + : () => _shareSelectedMessages(
78 + context,
79 + ref,
80 + selectedMessageIds,
81 + ),
82 + ),
83 + IconButton(
84 + icon: const HugeIcon(
85 + icon: HugeIcons.strokeRoundedDelete01,
86 + color: Colors.red,
87 + ),
88 + tooltip: l10n.delete,
89 + onPressed: selectedMessageIds.isEmpty
90 + ? null
91 + : () => _deleteSelectedMessages(
92 + context,
93 + ref,
94 + selectedMessageIds,
95 + ),
96 + ),
97 + ],
98 + ),
99 ),
100 );
101 }
@@ -101,136 +104,145 @@ class ScreenAppBar extends ConsumerWidget {
104 top: true,
105 bottom: false,
106 child: Container(
104 - height: 56,
105 - padding: const EdgeInsets.symmetric(horizontal: 4),
106 - child: Row(
107 - children: [
108 - ShadResponsiveBuilder(
109 - builder: (context, breakpoint) {
110 - final isDesktop =
111 - breakpoint >= ShadTheme.of(context).breakpoints.md;
112 - if (isDesktop) return const SizedBox.shrink();
113 - return IconButton(
114 - icon: const HugeIcon(icon: HugeIcons.strokeRoundedMenu01),
115 - onPressed: () => Scaffold.of(context).openDrawer(),
116 - );
117 - },
118 - ),
119 - const SizedBox(width: 8),
120 - Expanded(
121 - child: _TitleBlock(
122 - title: _appBarTitle(l10n),
123 - isDark: isDark,
107 + height: 56,
108 + padding: const EdgeInsets.symmetric(horizontal: 4),
109 + child: Row(
110 + children: [
111 + ShadResponsiveBuilder(
112 + builder: (context, breakpoint) {
113 + final isDesktop =
114 + breakpoint >= ShadTheme.of(context).breakpoints.md;
115 + if (isDesktop) return const SizedBox.shrink();
116 + return IconButton(
117 + icon: const HugeIcon(icon: HugeIcons.strokeRoundedMenu01),
118 + onPressed: () => Scaffold.of(context).openDrawer(),
119 + );
120 + },
121 ),
125 - ),
126 - Stack(
127 - children: [
128 - IconButton(
129 - icon: HugeIcon(
130 - icon: HugeIcons.strokeRoundedFilterHorizontal,
131 - size: 24,
132 - color: isDark ? Colors.white70 : Colors.black87,
133 - ),
134 - onPressed: () =>
135 - showChatSettingsSheet(context, initialTab: 'parameters'),
136 - tooltip: l10n.chat_parameters_tooltip,
137 - ),
138 - PositionedDirectional(
139 - top: 4,
140 - end: 0,
141 - child: Container(
142 - padding: const EdgeInsets.all(2),
143 - decoration: BoxDecoration(
144 - color: isMcpEnabled ? Colors.green : Colors.grey,
145 - shape: BoxShape.circle,
146 - ),
147 - child: const HugeIcon(
148 - icon: HugeIcons.strokeRoundedTools,
149 - size: 10,
150 - color: Colors.white,
122 + const SizedBox(width: 8),
123 + Expanded(
124 + child: _TitleBlock(title: _appBarTitle(l10n), isDark: isDark),
125 + ),
126 + Stack(
127 + children: [
128 + IconButton(
129 + icon: HugeIcon(
130 + icon: HugeIcons.strokeRoundedFilterHorizontal,
131 + size: 24,
132 + color: isDark ? Colors.white70 : Colors.black87,
133 ),
134 + onPressed: () =>
135 + showChatSettingsSheet(context, initialTab: 'parameters'),
136 + tooltip: l10n.chat_parameters_tooltip,
137 ),
153 - ),
154 - ],
155 - ),
156 - ChatModeIconButton(
157 - hasMessages: hasMessages,
158 - isTemporary: isTemporary,
159 - isDark: isDark,
160 - onPressed: onChatModeAction,
161 - ),
162 - PopupMenuButton<String>(
163 - icon: const HugeIcon(icon: HugeIcons.strokeRoundedMoreVertical),
164 - onSelected: onMenuAction,
165 - itemBuilder: (context) => [
166 - PopupMenuItem(
167 - value: 'new_chat',
168 - child: ListTile(
169 - leading: const HugeIcon(icon: HugeIcons.strokeRoundedAdd01),
170 - title: Text(l10n.nav_new_chat),
171 - contentPadding: EdgeInsets.zero,
172 - ),
173 - ),
174 - if (activeConversation != null && !isTemporary) ...[
175 - PopupMenuItem(
176 - value: 'rename',
177 - child: ListTile(
178 - leading: const HugeIcon(icon: HugeIcons.strokeRoundedPencilEdit02),
179 - title: Text(l10n.rename_conversation),
180 - contentPadding: EdgeInsets.zero,
138 + PositionedDirectional(
139 + top: 4,
140 + end: 0,
141 + child: Container(
142 + padding: const EdgeInsets.all(2),
143 + decoration: BoxDecoration(
144 + color: isMcpEnabled ? Colors.green : Colors.grey,
145 + shape: BoxShape.circle,
146 + ),
147 + child: const HugeIcon(
148 + icon: HugeIcons.strokeRoundedTools,
149 + size: 10,
150 + color: Colors.white,
151 + ),
152 ),
153 ),
154 + ],
155 + ),
156 + ChatModeIconButton(
157 + hasMessages: hasMessages,
158 + isTemporary: isTemporary,
159 + isDark: isDark,
160 + onPressed: onChatModeAction,
161 + ),
162 + PopupMenuButton<String>(
163 + icon: const HugeIcon(icon: HugeIcons.strokeRoundedMoreVertical),
164 + onSelected: onMenuAction,
165 + itemBuilder: (context) => [
166 PopupMenuItem(
184 - value: 'move_to_folder',
167 + value: 'new_chat',
168 child: ListTile(
186 - leading: const HugeIcon(icon: HugeIcons.strokeRoundedFolder01),
187 - title: Text(l10n.move_to_folder),
169 + leading: const HugeIcon(icon: HugeIcons.strokeRoundedAdd01),
170 + title: Text(l10n.nav_new_chat),
171 contentPadding: EdgeInsets.zero,
172 ),
173 ),
191 - ],
192 - if (hasMessages) ...[
174 + if (activeConversation != null && !isTemporary) ...[
175 + PopupMenuItem(
176 + value: 'rename',
177 + child: ListTile(
178 + leading: const HugeIcon(
179 + icon: HugeIcons.strokeRoundedPencilEdit02,
180 + ),
181 + title: Text(l10n.rename_conversation),
182 + contentPadding: EdgeInsets.zero,
183 + ),
184 + ),
185 + PopupMenuItem(
186 + value: 'move_to_folder',
187 + child: ListTile(
188 + leading: const HugeIcon(
189 + icon: HugeIcons.strokeRoundedFolder01,
190 + ),
191 + title: Text(l10n.move_to_folder),
192 + contentPadding: EdgeInsets.zero,
193 + ),
194 + ),
195 + ],
196 + if (hasMessages) ...[
197 + PopupMenuItem(
198 + value: 'export_chat',
199 + child: ListTile(
200 + leading: const HugeIcon(
201 + icon: HugeIcons.strokeRoundedUpload01,
202 + ),
203 + title: Text(l10n.export_conversation),
204 + contentPadding: EdgeInsets.zero,
205 + ),
206 + ),
207 + PopupMenuItem(
208 + value: 'share_chat',
209 + child: ListTile(
210 + leading: const HugeIcon(
211 + icon: HugeIcons.strokeRoundedShare01,
212 + ),
213 + title: Text(l10n.share_conversation),
214 + contentPadding: EdgeInsets.zero,
215 + ),
216 + ),
217 + ],
218 PopupMenuItem(
194 - value: 'export_chat',
219 + value: 'persona',
220 child: ListTile(
196 - leading: const HugeIcon(icon: HugeIcons.strokeRoundedUpload01),
197 - title: Text(l10n.export_conversation),
221 + leading: HugeIcon(
222 + icon: hasPersonas
223 + ? HugeIcons.strokeRoundedExchange01
224 + : HugeIcons.strokeRoundedRobot01,
225 + ),
226 + title: Text(
227 + hasPersonas ? l10n.change_persona : l10n.set_persona,
228 + ),
229 contentPadding: EdgeInsets.zero,
230 ),
231 ),
232 PopupMenuItem(
202 - value: 'share_chat',
233 + value: 'clear',
234 child: ListTile(
204 - leading: const HugeIcon(icon: HugeIcons.strokeRoundedShare01),
205 - title: Text(l10n.share_conversation),
235 + leading: const HugeIcon(
236 + icon: HugeIcons.strokeRoundedDelete01,
237 + ),
238 + title: Text(l10n.clear_conversation),
239 contentPadding: EdgeInsets.zero,
240 ),
241 ),
242 ],
210 - PopupMenuItem(
211 - value: 'persona',
212 - child: ListTile(
213 - leading: HugeIcon(icon:
214 - hasPersonas ? HugeIcons.strokeRoundedExchange01 : HugeIcons.strokeRoundedRobot01,
215 - ),
216 - title: Text(
217 - hasPersonas ? l10n.change_persona : l10n.set_persona,
218 - ),
219 - contentPadding: EdgeInsets.zero,
220 - ),
221 - ),
222 - PopupMenuItem(
223 - value: 'clear',
224 - child: ListTile(
225 - leading: const HugeIcon(icon: HugeIcons.strokeRoundedDelete01),
226 - title: Text(l10n.clear_conversation),
227 - contentPadding: EdgeInsets.zero,
228 - ),
229 - ),
230 - ],
231 - ),
232 - ],
233 - ),
243 + ),
244 + ],
245 + ),
246 ),
247 );
248 }
@@ -337,17 +349,25 @@ class ChatModeIconButton extends StatelessWidget {
349 color: activeColor.withValues(alpha: 0.25),
350 shape: BoxShape.circle,
351 ),
340 - child: HugeIcon(icon: HugeIcons.strokeRoundedIncognito, size: 20, color: activeColor),
352 + child: HugeIcon(
353 + icon: HugeIcons.strokeRoundedIncognito,
354 + size: 20,
355 + color: activeColor,
356 + ),
357 )
342 - : HugeIcon(icon: HugeIcons.strokeRoundedIncognito, size: 22, color: inactiveColor),
358 + : HugeIcon(
359 + icon: HugeIcons.strokeRoundedIncognito,
360 + size: 22,
361 + color: inactiveColor,
362 + ),
363 );
364 }
365
366 return IconButton(
367 onPressed: onPressed,
368 tooltip: l10n.nav_new_chat,
349 - icon: HugeIcon(icon:
350 - HugeIcons.strokeRoundedMessageAdd01,
369 + icon: HugeIcon(
370 + icon: HugeIcons.strokeRoundedMessageAdd01,
371 size: 22,
372 color: isDark ? Colors.white70 : Colors.black87,
373 ),
@@ -355,15 +375,11 @@ class ChatModeIconButton extends StatelessWidget {
375 }
376 }
377
358 -/// Title block shown in the chat app bar — conversation title on top,
359 -/// active server name directly below it with a small status dot
360 -/// (green when connected, grey otherwise). The row is intentionally
361 -/// non-interactive; server switching still lives in the sidebar.
378 +/// Title block shown in the chat app bar — conversation title on top and the
379 +/// active server name below. Model selection is already visible and editable
380 +/// from the model picker near the composer.
381 class _TitleBlock extends ConsumerWidget {
363 - const _TitleBlock({
364 - required this.title,
365 - required this.isDark,
366 - });
382 + const _TitleBlock({required this.title, required this.isDark});
383
384 final String title;
385 final bool isDark;
@@ -386,10 +402,7 @@ class _TitleBlock extends ConsumerWidget {
402 children: [
403 AutoSizeText(
404 title,
389 - style: const TextStyle(
390 - fontSize: 17,
391 - fontWeight: FontWeight.bold,
392 - ),
405 + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
406 maxLines: 1,
407 overflow: TextOverflow.ellipsis,
408 ),
@@ -417,10 +430,7 @@ class _TitleBlock extends ConsumerWidget {
430 ],
431 )
432 else
420 - Text(
421 - '',
422 - style: TextStyle(fontSize: 11, color: mutedColor),
423 - ),
433 + Text('', style: TextStyle(fontSize: 11, color: mutedColor)),
434 ],
435 ),
436 );
lib/features/models/components/model_list.dart
+38 -22
@@ -23,12 +23,14 @@ class ModelList extends ConsumerStatefulWidget {
23 required this.selectedModelId,
24 required this.isDark,
25 this.scrollController,
26 + this.closeOnSelect = true,
27 });
28
29 final String serverId;
30 final String? selectedModelId;
31 final bool isDark;
32 final ScrollController? scrollController;
33 + final bool closeOnSelect;
34
35 @override
36 ConsumerState<ModelList> createState() => _ModelListState();
@@ -98,10 +100,7 @@ class _ModelListState extends ConsumerState<ModelList> {
100 return sorted;
101 }
102
101 - Future<void> _showModelOptions(
102 - BuildContext context,
103 - ModelInfo model,
104 - ) async {
103 + Future<void> _showModelOptions(BuildContext context, ModelInfo model) async {
104 final l10n = AppLocalizations.of(context)!;
105 final metadata = ref.read(modelMetadataProvider)[model.id];
106 final noteController = TextEditingController(text: metadata?.note ?? '');
@@ -113,7 +112,7 @@ class _ModelListState extends ConsumerState<ModelList> {
112 final settings = ref.read(settingsProvider);
113 final isDefaultModel =
114 settings.defaultModelServerId == widget.serverId &&
116 - settings.defaultModelId == model.id;
115 + settings.defaultModelId == model.id;
116
117 return SafeArea(
118 child: Padding(
@@ -163,8 +162,8 @@ class _ModelListState extends ConsumerState<ModelList> {
162 ),
163 ListTile(
164 contentPadding: EdgeInsets.zero,
166 - leading: HugeIcon(icon:
167 - metadata?.isFavorite == true
165 + leading: HugeIcon(
166 + icon: metadata?.isFavorite == true
167 ? HugeIcons.strokeRoundedStar
168 : HugeIcons.strokeRoundedStar,
169 color: Colors.amber[700],
@@ -231,7 +230,11 @@ class _ModelListState extends ConsumerState<ModelList> {
230 child: Column(
231 mainAxisAlignment: MainAxisAlignment.center,
232 children: [
234 - HugeIcon(icon: HugeIcons.strokeRoundedCloud, size: 64, color: Colors.red[300]),
233 + HugeIcon(
234 + icon: HugeIcons.strokeRoundedCloud,
235 + size: 64,
236 + color: Colors.red[300],
237 + ),
238 const SizedBox(height: 16),
239 Text(
240 l10n.server_offline,
@@ -246,7 +249,9 @@ class _ModelListState extends ConsumerState<ModelList> {
249 l10n.could_not_establish_connection,
250 style: TextStyle(
251 fontSize: 13,
249 - color: widget.isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
252 + color: widget.isDark
253 + ? AppColors.darkMutedText
254 + : AppColors.lightMutedText,
255 ),
256 textAlign: TextAlign.center,
257 ),
@@ -277,7 +282,11 @@ class _ModelListState extends ConsumerState<ModelList> {
282 child: Column(
283 mainAxisAlignment: MainAxisAlignment.center,
284 children: [
280 - HugeIcon(icon: HugeIcons.strokeRoundedInformationCircle, size: 48, color: Colors.red[400]),
285 + HugeIcon(
286 + icon: HugeIcons.strokeRoundedInformationCircle,
287 + size: 48,
288 + color: Colors.red[400],
289 + ),
290 const SizedBox(height: 16),
291 Text(
292 l10n.failed_load_models,
@@ -292,7 +301,9 @@ class _ModelListState extends ConsumerState<ModelList> {
301 err.toString(),
302 style: TextStyle(
303 fontSize: 12,
295 - color: widget.isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
304 + color: widget.isDark
305 + ? AppColors.darkMutedText
306 + : AppColors.lightMutedText,
307 ),
308 textAlign: TextAlign.center,
309 ),
@@ -309,8 +320,9 @@ class _ModelListState extends ConsumerState<ModelList> {
320 data: (models) {
321 final serversAsync = ref.watch(serversProvider);
322 final servers = serversAsync.value ?? [];
312 - final activeServer =
313 - servers.where((s) => s.id == widget.serverId).firstOrNull;
323 + final activeServer = servers
324 + .where((s) => s.id == widget.serverId)
325 + .firstOrNull;
326 final loadedModelsAsync = activeServer != null
327 ? ref.watch(loadedModelsProvider(activeServer))
328 : const AsyncValue<Set<String>>.data(<String>{});
@@ -344,7 +356,9 @@ class _ModelListState extends ConsumerState<ModelList> {
356 : l10n.no_models_match(searchQuery),
357 style: TextStyle(
358 fontSize: 14,
347 - color: widget.isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
359 + color: widget.isDark
360 + ? AppColors.darkMutedText
361 + : AppColors.lightMutedText,
362 ),
363 ),
364 );
@@ -364,7 +378,8 @@ class _ModelListState extends ConsumerState<ModelList> {
378 isSelected: isSelected,
379 isLoaded: isLoaded,
380 isDark: widget.isDark,
367 - isLoading: modelLoading.isLoading && modelLoading.modelId == model.id,
381 + isLoading:
382 + modelLoading.isLoading && modelLoading.modelId == model.id,
383 isFavorite: modelMeta?.isFavorite ?? false,
384 isDefault: ref.watch(
385 settingsProvider.select(
@@ -387,8 +402,9 @@ class _ModelListState extends ConsumerState<ModelList> {
402 try {
403 final apiService = ref.read(serverApiServiceProvider);
404 final settings = ref.read(settingsProvider);
390 - final contextLength =
391 - ref.read(chatParamsProvider).contextLength;
405 + final contextLength = ref
406 + .read(chatParamsProvider)
407 + .contextLength;
408
409 if (settings.unloadModelsBeforeLoad) {
410 final instances = await ref.read(
@@ -423,7 +439,7 @@ class _ModelListState extends ConsumerState<ModelList> {
439 }
440
441 ref.read(selectedModelProvider.notifier).setModel(model);
426 - if (context.mounted) {
442 + if (widget.closeOnSelect && context.mounted) {
443 Navigator.pop(context);
444 }
445 },
@@ -452,9 +468,9 @@ class _ModelListState extends ConsumerState<ModelList> {
468 final message = activeServer.isOllamaFamily
469 ? l10n.model_unloaded_ollama(model.name)
470 : l10n.model_unloaded_success(model.name);
455 - ScaffoldMessenger.of(context).showSnackBar(
456 - SnackBar(content: Text(message)),
457 - );
471 + ScaffoldMessenger.of(
472 + context,
473 + ).showSnackBar(SnackBar(content: Text(message)));
474 }
475 } catch (e) {
476 if (context.mounted) {
@@ -473,4 +489,4 @@ class _ModelListState extends ConsumerState<ModelList> {
489 },
490 );
491 }
476 -}
\ No newline at end of file
492 +}
lib/features/on_device/components/on_device_picker_section.dart
+30 -16
@@ -28,11 +28,13 @@ class OnDevicePickerSection extends ConsumerWidget {
28 required this.selectedModelId,
29 required this.isDark,
30 this.scrollController,
31 + this.closeOnSelect = true,
32 });
33
34 final String? selectedModelId;
35 final bool isDark;
36 final ScrollController? scrollController;
37 + final bool closeOnSelect;
38
39 @override
40 Widget build(BuildContext context, WidgetRef ref) {
@@ -49,7 +51,11 @@ class OnDevicePickerSection extends ConsumerWidget {
51 child: Column(
52 mainAxisAlignment: MainAxisAlignment.center,
53 children: [
52 - HugeIcon(icon: HugeIcons.strokeRoundedInformationCircle, size: 48, color: Colors.red[400]),
54 + HugeIcon(
55 + icon: HugeIcons.strokeRoundedInformationCircle,
56 + size: 48,
57 + color: Colors.red[400],
58 + ),
59 const SizedBox(height: 16),
60 Text(
61 l10n.failed_load_models,
@@ -139,6 +145,7 @@ class OnDevicePickerSection extends ConsumerWidget {
145 isSelected: isSelected,
146 isDark: isDark,
147 deviceMemory: deviceMemoryAsync.value,
148 + closeOnSelect: closeOnSelect,
149 );
150 }
151
@@ -204,6 +211,7 @@ class _OnDeviceModelTile extends ConsumerWidget {
211 required this.isCurrentlyLoading,
212 required this.isSelected,
213 required this.isDark,
214 + required this.closeOnSelect,
215 this.deviceMemory,
216 });
217
@@ -213,6 +221,7 @@ class _OnDeviceModelTile extends ConsumerWidget {
221 final bool isCurrentlyLoading;
222 final bool isSelected;
223 final bool isDark;
224 + final bool closeOnSelect;
225 final DeviceMemoryInfo? deviceMemory;
226
227 @override
@@ -327,8 +336,8 @@ class _OnDeviceModelTile extends ConsumerWidget {
336 padding: const EdgeInsets.only(top: 2),
337 child: Row(
338 children: [
330 - const HugeIcon(icon:
331 - HugeIcons.strokeRoundedAlertCircle,
339 + const HugeIcon(
340 + icon: HugeIcons.strokeRoundedAlertCircle,
341 color: Colors.orange,
342 size: 12,
343 ),
@@ -360,7 +369,8 @@ class _OnDeviceModelTile extends ConsumerWidget {
369 Text(
370 isPaused
371 ? l10n.paused_progress(
363 - ((downloadProgress?.progress ?? 0) * 100)
372 + ((downloadProgress?.progress ?? 0) *
373 + 100)
374 .toStringAsFixed(0),
375 )
376 : '${l10n.downloading_status} ${((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0)}%',
@@ -425,8 +435,8 @@ class _OnDeviceModelTile extends ConsumerWidget {
435 ),
436 const SizedBox(width: 8),
437 _IconButton(
428 - icon: HugeIcon(icon:
429 - HugeIcons.strokeRoundedPower,
438 + icon: HugeIcon(
439 + icon: HugeIcons.strokeRoundedPower,
440 size: 18,
441 color: Colors.red[400],
442 ),
@@ -435,14 +445,18 @@ class _OnDeviceModelTile extends ConsumerWidget {
445 ),
446 ] else if (isDownloaded && !isCurrentlyLoading) ...[
447 _IconButton(
438 - icon: HugeIcon(icon: HugeIcons.strokeRoundedPlay, size: 20, color: accent),
448 + icon: HugeIcon(
449 + icon: HugeIcons.strokeRoundedPlay,
450 + size: 20,
451 + color: accent,
452 + ),
453 tooltip: l10n.load,
454 onPressed: () => _loadModel(context, ref),
455 ),
456 const SizedBox(width: 4),
457 _IconButton(
444 - icon: HugeIcon(icon:
445 - HugeIcons.strokeRoundedDelete01,
458 + icon: HugeIcon(
459 + icon: HugeIcons.strokeRoundedDelete01,
460 size: 18,
461 color: isDark
462 ? AppColors.darkMutedText
@@ -453,8 +467,8 @@ class _OnDeviceModelTile extends ConsumerWidget {
467 ),
468 ] else if (isDownloading) ...[
469 _IconButton(
456 - icon: HugeIcon(icon:
457 - HugeIcons.strokeRoundedCancel01,
470 + icon: HugeIcon(
471 + icon: HugeIcons.strokeRoundedCancel01,
472 size: 16,
473 color: isDark
474 ? AppColors.darkMutedText
@@ -467,8 +481,8 @@ class _OnDeviceModelTile extends ConsumerWidget {
481 ),
482 ] else if (!isCurrentlyLoading) ...[
483 _IconButton(
470 - icon: HugeIcon(icon:
471 - HugeIcons.strokeRoundedCloudDownload,
484 + icon: HugeIcon(
485 + icon: HugeIcons.strokeRoundedCloudDownload,
486 size: 18,
487 color: isDark
488 ? AppColors.darkMutedText
@@ -583,7 +597,7 @@ class _OnDeviceModelTile extends ConsumerWidget {
597 final engineState = ref.read(onDeviceEngineProvider);
598 if (engineState.loadedModelId == model.id) {
599 ref.read(selectedModelProvider.notifier).setModel(modelInfo);
586 - Navigator.pop(context);
600 + if (closeOnSelect) Navigator.pop(context);
601 }
602 }
603
@@ -623,7 +637,7 @@ class _OnDeviceModelTile extends ConsumerWidget {
637 );
638
639 ref.read(selectedModelProvider.notifier).setModel(modelInfo);
626 - Navigator.pop(context);
640 + if (closeOnSelect) Navigator.pop(context);
641 }
642
643 void _unloadModel(BuildContext context, WidgetRef ref) async {
@@ -699,4 +713,4 @@ class _IconButton extends StatelessWidget {
713 ),
714 );
715 }
702 -}
\ No newline at end of file
716 +}
lib/features/on_device/providers/on_device_providers.dart
+3 -2
@@ -306,8 +306,9 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
306 final activeServer = ref.read(activeServerProvider);
307 if (activeServer?.type != ServerType.onDevice) {
308 final servers = await ref.read(serversProvider.future);
309 - final onDeviceServer =
310 - servers.where((s) => s.type == ServerType.onDevice).firstOrNull;
309 + final onDeviceServer = servers
310 + .where((s) => s.type == ServerType.onDevice)
311 + .firstOrNull;
312 if (onDeviceServer != null) {
313 ref
314 .read(activeServerIdProvider.notifier)
lib/features/voice_mode/providers/voice_mode_provider.dart
+41 -11
@@ -106,18 +106,28 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
106 (previous, next) => _onTtsStateChanged(previous, next),
107 );
108
109 + // A server or genuinely loaded model can become available while the voice
110 + // overlay is open. Resume automatically instead of leaving the user on a
111 + // stale selection error.
112 + ref.listen<ActiveChatTarget>(activeChatTargetProvider, (previous, next) {
113 + final waitingForTarget =
114 + state.phase == VoiceModePhase.error &&
115 + (state.error == modelSelectionRequiredMessage ||
116 + state.error == 'No server connected');
117 + if (_active && waitingForTarget && next.isReady) {
118 + unawaited(startListening());
119 + }
120 + });
121 +
122 // Listen to STT errors and show them directly on the voice screen.
110 - ref.listen<String?>(
111 - sttProvider.select((s) => s.error),
112 - (previous, next) {
113 - if (!_active || next == null) return;
114 - state = state.copyWith(
115 - phase: VoiceModePhase.error,
116 - error: _mapSttError(next),
117 - );
118 - ref.read(voiceFeedbackProvider).playDisconnected();
119 - },
120 - );
123 + ref.listen<String?>(sttProvider.select((s) => s.error), (previous, next) {
124 + if (!_active || next == null) return;
125 + state = state.copyWith(
126 + phase: VoiceModePhase.error,
127 + error: _mapSttError(next),
128 + );
129 + ref.read(voiceFeedbackProvider).playDisconnected();
130 + });
131
132 return const VoiceModeState();
133 }
@@ -131,6 +141,7 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
141 _active = true;
142 _generatingFired = false;
143 state = const VoiceModeState(isActive: true, phase: VoiceModePhase.idle);
144 + if (!_ensureChatTarget()) return;
145 ref.read(voiceFeedbackProvider).playConnected();
146 // Auto-start listening.
147 startListening();
@@ -139,6 +150,7 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
150 /// Start listening for user speech.
151 Future<void> startListening() async {
152 if (!_active) return;
153 + if (!_ensureChatTarget()) return;
154
155 _isSendingTranscript = false;
156 state = state.copyWith(
@@ -198,6 +210,8 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
210 return;
211 }
212
213 + if (!_ensureChatTarget()) return;
214 +
215 // Reset the mic level so the waveform visualizer doesn't keep
216 // dancing on a stale value once we leave the listening phase.
217 state = state.copyWith(
@@ -359,6 +373,22 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
373 state = state.copyWith(phase: VoiceModePhase.idle);
374 }
375 }
376 +
377 + bool _ensureChatTarget() {
378 + final target = ref.read(activeChatTargetProvider);
379 + if (target.isReady) return true;
380 +
381 + _isSendingTranscript = false;
382 + state = state.copyWith(
383 + isActive: true,
384 + phase: VoiceModePhase.error,
385 + error: target.server == null
386 + ? 'No server connected'
387 + : modelSelectionRequiredMessage,
388 + micLevel: 0,
389 + );
390 + return false;
391 + }
392 }
393
394 String _mapSttError(String error) {
lib/features/voice_mode/views/voice_mode_overlay.dart
+248 -24
@@ -9,7 +9,12 @@ import 'package:hugeicons/hugeicons.dart';
9 import '../../../../core/models/enums.dart';
10 import '../../../../core/providers/app_providers.dart';
11 import '../../../../core/services/app_haptics.dart';
12 +import '../../../../l10n/app_localizations.dart';
13 import '../../chat/providers/chat_notifier.dart';
14 +import '../../chat/providers/model_selection_providers.dart';
15 +import '../../models/components/model_list.dart';
16 +import '../../on_device/components/on_device_picker_section.dart';
17 +import '../../servers/providers/server_providers.dart';
18 import '../../tts/providers/tts_providers.dart';
19 import '../../tts/views/tts_model_manager_screen.dart';
20 import '../providers/voice_mode_provider.dart';
@@ -182,6 +187,12 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
187 225.0,
188 255.0,
189 );
190 + final target = ref.watch(activeChatTargetProvider);
191 + final showInlineModelPicker = target.server != null && !target.isReady;
192 + final modelPickerHeight = (MediaQuery.sizeOf(context).height / 3).clamp(
193 + 220.0,
194 + 380.0,
195 + );
196
197 return PopScope(
198 canPop: false,
@@ -257,32 +268,81 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
268 ],
269 ),
270 ),
260 -
261 - const Spacer(flex: 2),
262 -
263 - GestureDetector(
264 - onTap: _handleCenterAction,
265 - child: VoiceVisualizer(
266 - phase: state.phase,
267 - micLevel: state.micLevel,
268 - responseProgress: responseProgress,
269 - size: orbSize,
271 + if (showInlineModelPicker) ...[
272 + Expanded(
273 + child: LayoutBuilder(
274 + builder: (context, constraints) {
275 + final compactOrbSize =
276 + (constraints.maxHeight * 0.52).clamp(
277 + 110.0,
278 + orbSize,
279 + );
280 + return Column(
281 + mainAxisAlignment: MainAxisAlignment.center,
282 + children: [
283 + GestureDetector(
284 + onTap: _handleCenterAction,
285 + child: VoiceVisualizer(
286 + phase: state.phase,
287 + micLevel: state.micLevel,
288 + responseProgress: responseProgress,
289 + size: compactOrbSize,
290 + ),
291 + ),
292 + const SizedBox(height: 12),
293 + Flexible(
294 + child: Padding(
295 + padding: const EdgeInsets.symmetric(
296 + horizontal: 32,
297 + ),
298 + child: _StreamingTranscript(
299 + phase: state.phase,
300 + transcript: state.transcript,
301 + response: state.response,
302 + error: state.error,
303 + ),
304 + ),
305 + ),
306 + ],
307 + );
308 + },
309 + ),
310 ),
271 - ),
272 -
273 - const SizedBox(height: 28),
274 -
275 - Padding(
276 - padding: const EdgeInsets.symmetric(horizontal: 32),
277 - child: _StreamingTranscript(
278 - phase: state.phase,
279 - transcript: state.transcript,
280 - response: state.response,
281 - error: state.error,
311 + Padding(
312 + padding: const EdgeInsets.symmetric(horizontal: 20),
313 + child: SizedBox(
314 + height: modelPickerHeight,
315 + child: const VoiceModelPickerPanel(),
316 + ),
317 ),
283 - ),
284 -
285 - const Spacer(flex: 3),
318 + const SizedBox(height: 10),
319 + ] else ...[
320 + const Spacer(flex: 2),
321 + GestureDetector(
322 + onTap: _handleCenterAction,
323 + child: VoiceVisualizer(
324 + phase: state.phase,
325 + micLevel: state.micLevel,
326 + responseProgress: responseProgress,
327 + size: orbSize,
328 + ),
329 + ),
330 + const SizedBox(height: 28),
331 + Padding(
332 + padding: const EdgeInsets.symmetric(horizontal: 32),
333 + child: _StreamingTranscript(
334 + phase: state.phase,
335 + transcript: state.transcript,
336 + response: state.response,
337 + error: state.error,
338 + ),
339 + ),
340 + const Spacer(flex: 3),
341 + const Padding(
342 + padding: EdgeInsets.fromLTRB(20, 0, 20, 12),
343 + child: Center(child: VoiceTargetChip()),
344 + ),
345 + ],
346
347 VoiceModeControls(
348 phase: state.phase,
@@ -313,6 +373,170 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
373 }
374 }
375
376 +class VoiceModelPickerPanel extends ConsumerWidget {
377 + const VoiceModelPickerPanel({super.key});
378 +
379 + @override
380 + Widget build(BuildContext context, WidgetRef ref) {
381 + final l10n = AppLocalizations.of(context)!;
382 + final server = ref.watch(activeServerProvider);
383 + if (server == null) return const SizedBox.shrink();
384 +
385 + final selectedModel = ref.watch(selectedModelProvider);
386 + final isDark = Theme.of(context).brightness == Brightness.dark;
387 + final foreground = isDark ? Colors.white : const Color(0xFF25243A);
388 + final surface = isDark
389 + ? const Color(0xFF151720).withValues(alpha: 0.94)
390 + : Colors.white.withValues(alpha: 0.88);
391 +
392 + return Semantics(
393 + container: true,
394 + label: '${l10n.select_model_title}, ${server.name}',
395 + child: Container(
396 + key: const ValueKey('voice-model-picker-panel'),
397 + padding: const EdgeInsets.fromLTRB(14, 12, 14, 8),
398 + decoration: BoxDecoration(
399 + color: surface,
400 + borderRadius: BorderRadius.circular(22),
401 + border: Border.all(color: foreground.withValues(alpha: 0.10)),
402 + boxShadow: [
403 + BoxShadow(
404 + color: Colors.black.withValues(alpha: isDark ? 0.22 : 0.07),
405 + blurRadius: 22,
406 + offset: const Offset(0, 8),
407 + ),
408 + ],
409 + ),
410 + child: Column(
411 + crossAxisAlignment: CrossAxisAlignment.start,
412 + children: [
413 + Row(
414 + children: [
415 + Icon(
416 + Icons.memory_rounded,
417 + size: 16,
418 + color: foreground.withValues(alpha: 0.62),
419 + ),
420 + const SizedBox(width: 7),
421 + Expanded(
422 + child: Text(
423 + l10n.select_model_title,
424 + maxLines: 1,
425 + overflow: TextOverflow.ellipsis,
426 + style: TextStyle(
427 + fontSize: 14,
428 + fontWeight: FontWeight.w600,
429 + color: foreground.withValues(alpha: 0.86),
430 + ),
431 + ),
432 + ),
433 + Text(
434 + server.name,
435 + maxLines: 1,
436 + overflow: TextOverflow.ellipsis,
437 + style: TextStyle(
438 + fontSize: 11,
439 + color: foreground.withValues(alpha: 0.48),
440 + ),
441 + ),
442 + ],
443 + ),
444 + const SizedBox(height: 8),
445 + Expanded(
446 + child: server.type == ServerType.onDevice
447 + ? OnDevicePickerSection(
448 + selectedModelId: selectedModel?.id,
449 + isDark: isDark,
450 + closeOnSelect: false,
451 + )
452 + : ModelList(
453 + serverId: server.id,
454 + selectedModelId: selectedModel?.id,
455 + isDark: isDark,
456 + closeOnSelect: false,
457 + ),
458 + ),
459 + ],
460 + ),
461 + ),
462 + );
463 + }
464 +}
465 +
466 +class VoiceTargetChip extends ConsumerWidget {
467 + const VoiceTargetChip({super.key});
468 +
469 + @override
470 + Widget build(BuildContext context, WidgetRef ref) {
471 + final target = ref.watch(activeChatTargetProvider);
472 + final server = target.server;
473 + if (server == null) return const SizedBox.shrink();
474 +
475 + final theme = Theme.of(context);
476 + final isDark = theme.brightness == Brightness.dark;
477 + final foreground = isDark ? Colors.white : const Color(0xFF25243A);
478 +
479 + return Semantics(
480 + label: 'Active server ${server.name}, model ${target.modelLabel}',
481 + child: Container(
482 + constraints: const BoxConstraints(maxWidth: 320),
483 + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
484 + decoration: BoxDecoration(
485 + color: foreground.withValues(alpha: isDark ? 0.055 : 0.035),
486 + borderRadius: BorderRadius.circular(999),
487 + border: Border.all(
488 + color: foreground.withValues(alpha: isDark ? 0.10 : 0.075),
489 + ),
490 + ),
491 + child: Row(
492 + mainAxisSize: MainAxisSize.min,
493 + children: [
494 + Icon(
495 + Icons.dns_outlined,
496 + size: 12,
497 + color: foreground.withValues(alpha: 0.55),
498 + ),
499 + const SizedBox(width: 5),
500 + Flexible(
501 + child: Text(
502 + server.name,
503 + maxLines: 1,
504 + overflow: TextOverflow.ellipsis,
505 + style: TextStyle(
506 + fontSize: 11,
507 + fontWeight: FontWeight.w500,
508 + color: foreground.withValues(alpha: 0.62),
509 + ),
510 + ),
511 + ),
512 + Padding(
513 + padding: const EdgeInsets.symmetric(horizontal: 6),
514 + child: Text(
515 + '•',
516 + style: TextStyle(
517 + fontSize: 10,
518 + color: foreground.withValues(alpha: 0.32),
519 + ),
520 + ),
521 + ),
522 + Flexible(
523 + child: Text(
524 + target.modelLabel,
525 + maxLines: 1,
526 + overflow: TextOverflow.ellipsis,
527 + style: TextStyle(
528 + fontSize: 11,
529 + color: foreground.withValues(alpha: 0.52),
530 + ),
531 + ),
532 + ),
533 + ],
534 + ),
535 + ),
536 + );
537 + }
538 +}
539 +
540 /// Subscribes to the chat streaming message and feeds its content into
541 /// [VoiceTranscript] during the processing phase.
542 class _StreamingTranscript extends ConsumerWidget {
test/features/chat/providers/active_chat_target_provider_test.dart new
+88
@@ -0,0 +1,88 @@
1 +import 'package:flutter_riverpod/flutter_riverpod.dart';
2 +import 'package:flutter_test/flutter_test.dart';
3 +import 'package:localmind/core/models/enums.dart';
4 +import 'package:localmind/features/chat/providers/model_selection_providers.dart';
5 +import 'package:localmind/features/on_device/providers/on_device_providers.dart';
6 +import 'package:localmind/features/servers/data/models/server.dart';
7 +import 'package:localmind/features/servers/providers/server_providers.dart';
8 +
9 +void main() {
10 + test('uses a loaded on-device model without an explicit selection', () {
11 + final container = ProviderContainer(
12 + overrides: [
13 + activeServerProvider.overrideWith(_OnDeviceServerNotifier.new),
14 + onDeviceEngineProvider.overrideWith(_LoadedEngineNotifier.new),
15 + ],
16 + );
17 + addTearDown(container.dispose);
18 +
19 + final target = container.read(activeChatTargetProvider);
20 +
21 + expect(target.isReady, isTrue);
22 + expect(target.effectiveModelId, 'smollm2-135m-instruct');
23 + expect(target.modelLabel, 'SmolLM 135M Instruct');
24 + });
25 +
26 + test('does not treat a native restored spec as a loaded model', () {
27 + final container = ProviderContainer(
28 + overrides: [
29 + activeServerProvider.overrideWith(_OnDeviceServerNotifier.new),
30 + onDeviceEngineProvider.overrideWith(_EmptyEngineNotifier.new),
31 + ],
32 + );
33 + addTearDown(container.dispose);
34 +
35 + final target = container.read(activeChatTargetProvider);
36 +
37 + expect(target.isReady, isFalse);
38 + expect(target.effectiveModelId, isNull);
39 + expect(target.modelLabel, 'No model');
40 + });
41 +
42 + test('keeps default routing for a selected remote server', () {
43 + final container = ProviderContainer(
44 + overrides: [activeServerProvider.overrideWith(_RemoteServerNotifier.new)],
45 + );
46 + addTearDown(container.dispose);
47 +
48 + final target = container.read(activeChatTargetProvider);
49 +
50 + expect(target.isReady, isTrue);
51 + expect(target.effectiveModelId, 'default');
52 + expect(target.modelLabel, 'Default model');
53 + });
54 +}
55 +
56 +class _OnDeviceServerNotifier extends ActiveServerNotifier {
57 + @override
58 + Server? build() => _server(ServerType.onDevice, 'On-Device');
59 +}
60 +
61 +class _RemoteServerNotifier extends ActiveServerNotifier {
62 + @override
63 + Server? build() => _server(ServerType.ollama, 'Ollama');
64 +}
65 +
66 +class _LoadedEngineNotifier extends OnDeviceEngineNotifier {
67 + @override
68 + OnDeviceEngineState build() => const OnDeviceEngineState(
69 + status: OnDeviceEngineStatus.loaded,
70 + loadedModelId: 'smollm2-135m-instruct',
71 + );
72 +}
73 +
74 +class _EmptyEngineNotifier extends OnDeviceEngineNotifier {
75 + @override
76 + OnDeviceEngineState build() => const OnDeviceEngineState();
77 +}
78 +
79 +Server _server(ServerType type, String name) => Server(
80 + id: type == ServerType.onDevice ? 'on-device' : 'remote',
81 + name: name,
82 + type: type,
83 + host: 'localhost',
84 + port: 11434,
85 + createdAt: DateTime.utc(2026, 8, 13),
86 + lastConnectedAt: DateTime.utc(2026, 8, 13),
87 + status: ConnectionStatus.connected,
88 +);
test/features/chat/providers/chat_notifier_model_guard_test.dart new
+53
@@ -0,0 +1,53 @@
1 +import 'package:flutter_riverpod/flutter_riverpod.dart';
2 +import 'package:flutter_test/flutter_test.dart';
3 +import 'package:localmind/core/models/enums.dart';
4 +import 'package:localmind/features/chat/providers/chat_notifier.dart';
5 +import 'package:localmind/features/chat/providers/model_selection_providers.dart';
6 +import 'package:localmind/features/on_device/providers/on_device_providers.dart';
7 +import 'package:localmind/features/servers/data/models/server.dart';
8 +import 'package:localmind/features/servers/providers/server_providers.dart';
9 +
10 +void main() {
11 + test('sendMessage refuses to send when no model is selected', () async {
12 + final container = ProviderContainer(
13 + overrides: [
14 + activeServerProvider.overrideWith(_ConnectedServerNotifier.new),
15 + onDeviceEngineProvider.overrideWith(_EmptyEngineNotifier.new),
16 + chatProvider.overrideWith(_GuardTestChatNotifier.new),
17 + ],
18 + );
19 + addTearDown(container.dispose);
20 +
21 + await container.read(chatProvider.notifier).sendMessage('hello');
22 +
23 + final state = container.read(chatProvider);
24 + expect(state.errorMessage, modelSelectionRequiredMessage);
25 + expect(state.messages, isEmpty);
26 + expect(state.allMessages, isEmpty);
27 + expect(state.isStreaming, isFalse);
28 + });
29 +}
30 +
31 +class _EmptyEngineNotifier extends OnDeviceEngineNotifier {
32 + @override
33 + OnDeviceEngineState build() => const OnDeviceEngineState();
34 +}
35 +
36 +class _GuardTestChatNotifier extends ChatNotifier {
37 + @override
38 + ChatState build() => const ChatState();
39 +}
40 +
41 +class _ConnectedServerNotifier extends ActiveServerNotifier {
42 + @override
43 + Server? build() => Server(
44 + id: 'on-device',
45 + name: 'On-Device',
46 + type: ServerType.onDevice,
47 + host: '',
48 + port: 0,
49 + createdAt: DateTime.utc(2026, 8, 13),
50 + lastConnectedAt: DateTime.utc(2026, 8, 13),
51 + status: ConnectionStatus.connected,
52 + );
53 +}
test/features/chat/smart_reply_service_single_flight_test.dart
+14
@@ -11,6 +11,20 @@ import 'package:localmind/features/chat/data/tools/tool_definition.dart';
11 import 'package:localmind/features/servers/data/models/server.dart';
12
13 void main() {
14 + test('does not treat hi inside Chicago as a greeting', () {
15 + final replies = SmartReplyService().getFallbackReplies(
16 + 'The conference in Chicago is next month.',
17 + );
18 +
19 + expect(replies, isNot(contains('Hello! How are you?')));
20 + });
21 +
22 + test('still recognizes hi as a standalone greeting', () {
23 + final replies = SmartReplyService().getFallbackReplies('Hi there!');
24 +
25 + expect(replies, contains('Hello! How are you?'));
26 + });
27 +
28 test(
29 'shares an in-flight suggestion request for the same assistant message',
30 () async {
test/features/voice_mode/voice_mode_model_guard_test.dart new
+87
@@ -0,0 +1,87 @@
1 +import 'package:flutter_riverpod/flutter_riverpod.dart';
2 +import 'package:flutter_test/flutter_test.dart';
3 +import 'package:localmind/features/chat/providers/chat_notifier.dart';
4 +import 'package:localmind/features/chat/providers/model_selection_providers.dart';
5 +import 'package:localmind/core/models/enums.dart';
6 +import 'package:localmind/features/on_device/providers/on_device_providers.dart';
7 +import 'package:localmind/features/servers/data/models/server.dart';
8 +import 'package:localmind/features/servers/providers/server_providers.dart';
9 +import 'package:localmind/features/stt/providers/stt_providers.dart';
10 +import 'package:localmind/features/tts/providers/tts_providers.dart';
11 +import 'package:localmind/features/voice_mode/providers/voice_mode_provider.dart';
12 +
13 +void main() {
14 + TestWidgetsFlutterBinding.ensureInitialized();
15 +
16 + test('voice mode does not listen when no model is selected', () async {
17 + final stt = _RecordingSttNotifier();
18 + final container = ProviderContainer(
19 + overrides: [
20 + sttProvider.overrideWith(() => stt),
21 + ttsProvider.overrideWith(_StubTtsNotifier.new),
22 + chatProvider.overrideWith(_StubChatNotifier.new),
23 + activeServerProvider.overrideWith(_ConnectedServerNotifier.new),
24 + onDeviceEngineProvider.overrideWith(_EmptyEngineNotifier.new),
25 + ],
26 + );
27 + addTearDown(container.dispose);
28 +
29 + final notifier = container.read(voiceModeProvider.notifier);
30 + notifier.startSession();
31 +
32 + expect(container.read(voiceModeProvider).phase, VoiceModePhase.error);
33 + expect(
34 + container.read(voiceModeProvider).error,
35 + modelSelectionRequiredMessage,
36 + );
37 + expect(stt.startCount, 0);
38 +
39 + await notifier.startListening();
40 + expect(stt.startCount, 0);
41 + });
42 +}
43 +
44 +class _ConnectedServerNotifier extends ActiveServerNotifier {
45 + @override
46 + Server? build() => Server(
47 + id: 'on-device',
48 + name: 'On-Device',
49 + type: ServerType.onDevice,
50 + host: '',
51 + port: 0,
52 + createdAt: DateTime.utc(2026, 8, 13),
53 + lastConnectedAt: DateTime.utc(2026, 8, 13),
54 + status: ConnectionStatus.connected,
55 + );
56 +}
57 +
58 +class _EmptyEngineNotifier extends OnDeviceEngineNotifier {
59 + @override
60 + OnDeviceEngineState build() => const OnDeviceEngineState();
61 +}
62 +
63 +class _RecordingSttNotifier extends SttNotifier {
64 + int startCount = 0;
65 +
66 + @override
67 + SttState build() => const SttState(isAvailable: true);
68 +
69 + @override
70 + Future<void> startListening({
71 + required void Function(String) onResult,
72 + void Function(String)? onFinal,
73 + SoundLevelChange? onSoundLevelChange,
74 + }) async {
75 + startCount++;
76 + }
77 +}
78 +
79 +class _StubTtsNotifier extends TtsNotifier {
80 + @override
81 + TtsState build() => const TtsState();
82 +}
83 +
84 +class _StubChatNotifier extends ChatNotifier {
85 + @override
86 + ChatState build() => const ChatState();
87 +}
test/features/voice_mode/voice_target_chip_test.dart new
+138
@@ -0,0 +1,138 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:flutter_test/flutter_test.dart';
4 +import 'package:localmind/core/device/device_memory_service.dart';
5 +import 'package:localmind/core/models/enums.dart';
6 +import 'package:localmind/core/providers/device_info_providers.dart';
7 +import 'package:localmind/core/providers/storage_providers.dart';
8 +import 'package:localmind/features/chat/providers/model_selection_providers.dart';
9 +import 'package:localmind/features/on_device/data/models/on_device_model.dart';
10 +import 'package:localmind/features/on_device/providers/on_device_providers.dart';
11 +import 'package:localmind/features/servers/data/models/server.dart';
12 +import 'package:localmind/features/servers/providers/server_providers.dart';
13 +import 'package:localmind/features/voice_mode/views/voice_mode_overlay.dart';
14 +import 'package:localmind/l10n/app_localizations.dart';
15 +import 'package:shared_preferences/shared_preferences.dart';
16 +
17 +void main() {
18 + testWidgets('shows the effective server and model without overflowing', (
19 + tester,
20 + ) async {
21 + final target = ActiveChatTarget(
22 + server: Server(
23 + id: 'on-device',
24 + name: 'On-Device',
25 + type: ServerType.onDevice,
26 + host: '',
27 + port: 0,
28 + createdAt: DateTime.utc(2026, 8, 13),
29 + lastConnectedAt: DateTime.utc(2026, 8, 13),
30 + status: ConnectionStatus.connected,
31 + ),
32 + selectedModel: null,
33 + effectiveModelId: 'smollm2-135m-instruct',
34 + modelLabel: 'SmolLM 135M Instruct',
35 + );
36 +
37 + await tester.pumpWidget(
38 + ProviderScope(
39 + overrides: [activeChatTargetProvider.overrideWith((ref) => target)],
40 + child: const MaterialApp(
41 + home: Scaffold(
42 + body: Align(
43 + alignment: Alignment.topLeft,
44 + child: SizedBox(width: 210, child: VoiceTargetChip()),
45 + ),
46 + ),
47 + ),
48 + ),
49 + );
50 +
51 + expect(find.text('On-Device'), findsOneWidget);
52 + expect(find.text('SmolLM 135M Instruct'), findsOneWidget);
53 + expect(tester.takeException(), isNull);
54 + });
55 +
56 + testWidgets('inline picker scrolls and selects without closing voice mode', (
57 + tester,
58 + ) async {
59 + SharedPreferences.setMockInitialValues({});
60 + final prefs = await SharedPreferences.getInstance();
61 + final model = OnDeviceModel.curatedModels.firstWhere(
62 + (candidate) => candidate.id == 'smollm2-135m-instruct',
63 + );
64 +
65 + final container = ProviderContainer(
66 + overrides: [
67 + sharedPreferencesProvider.overrideWithValue(prefs),
68 + activeServerProvider.overrideWith(_OnDeviceServerNotifier.new),
69 + onDeviceEngineProvider.overrideWith(_LoadedEngineNotifier.new),
70 + onDeviceModelsProvider.overrideWith((ref) => [model]),
71 + downloadedModelsProvider.overrideWith((ref) async => {model.id}),
72 + deviceMemoryProvider.overrideWith(
73 + (ref) async => const DeviceMemoryInfo(
74 + totalMemoryMb: 8192,
75 + availableMemoryMb: 8192,
76 + ),
77 + ),
78 + ],
79 + );
80 + addTearDown(container.dispose);
81 +
82 + await tester.pumpWidget(
83 + UncontrolledProviderScope(
84 + container: container,
85 + child: MaterialApp(
86 + localizationsDelegates: AppLocalizations.localizationsDelegates,
87 + supportedLocales: AppLocalizations.supportedLocales,
88 + home: const Scaffold(
89 + body: SizedBox(
90 + width: 390,
91 + height: 300,
92 + child: VoiceModelPickerPanel(),
93 + ),
94 + ),
95 + ),
96 + ),
97 + );
98 + await tester.pumpAndSettle();
99 +
100 + expect(
101 + find.byKey(const ValueKey('voice-model-picker-panel')),
102 + findsOneWidget,
103 + );
104 + expect(find.byType(Scrollable), findsWidgets);
105 +
106 + await tester.tap(find.text('SmolLM 135M Instruct'));
107 + await tester.pump();
108 +
109 + expect(
110 + find.byKey(const ValueKey('voice-model-picker-panel')),
111 + findsOneWidget,
112 + );
113 + expect(container.read(selectedModelProvider)?.id, model.id);
114 + expect(tester.takeException(), isNull);
115 + });
116 +}
117 +
118 +class _OnDeviceServerNotifier extends ActiveServerNotifier {
119 + @override
120 + Server? build() => Server(
121 + id: 'on-device',
122 + name: 'On-Device',
123 + type: ServerType.onDevice,
124 + host: '',
125 + port: 0,
126 + createdAt: DateTime.utc(2026, 8, 13),
127 + lastConnectedAt: DateTime.utc(2026, 8, 13),
128 + status: ConnectionStatus.connected,
129 + );
130 +}
131 +
132 +class _LoadedEngineNotifier extends OnDeviceEngineNotifier {
133 + @override
134 + OnDeviceEngineState build() => const OnDeviceEngineState(
135 + status: OnDeviceEngineStatus.loaded,
136 + loadedModelId: 'smollm2-135m-instruct',
137 + );
138 +}