@tduncan / Cortana / commits / 80cc341

feat: add auto-collapse setting for reasoning content with corresponding UI implementation and tests #28

abdulmominsakib committed Aug 21, 2026 at 11:42 UTC 80cc341ed4750005359a34b9381a3eeff84c9355
32 files changed +514 -44
lib/core/providers/app_providers.dart
+13 -14
@@ -81,17 +81,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
81 void setDefaultPersona(String? id) =>
82 _update(state.copyWith(defaultPersonaId: id));
83 void setDefaultModel({String? serverId, String? modelId}) => _update(
84 - state.copyWith(
85 - defaultModelServerId: serverId,
86 - defaultModelId: modelId,
87 - ),
88 - );
89 - void clearDefaultModel() => _update(
90 - state.copyWith(
91 - defaultModelServerId: null,
92 - defaultModelId: null,
93 - ),
94 - );
84 + state.copyWith(defaultModelServerId: serverId, defaultModelId: modelId),
85 + );
86 + void clearDefaultModel() =>
87 + _update(state.copyWith(defaultModelServerId: null, defaultModelId: null));
88 void setHasCompletedOnboarding(bool value) =>
89 _update(state.copyWith(hasCompletedOnboarding: value));
90 void setMcpEnabled(bool value) => _update(state.copyWith(mcpEnabled: value));
@@ -146,12 +139,18 @@ class SettingsNotifier extends Notifier<AppSettings> {
139 _update(state.copyWith(showSystemMessagesInChat: value));
140 void setCalendarToolsEnabled(bool value) =>
141 _update(state.copyWith(calendarToolsEnabled: value));
142 + void setAutoCollapseThinking(bool value) =>
143 + _update(state.copyWith(autoCollapseThinking: value));
144
145 void addSavedMcpIntegration(McpIntegration integration) {
146 final current = List<McpIntegration>.from(state.savedMcpIntegrations);
152 - final exists = current.any((i) =>
153 - (integration.pluginId != null && i.pluginId == integration.pluginId) ||
154 - (integration.serverUrl != null && i.serverUrl == integration.serverUrl));
147 + final exists = current.any(
148 + (i) =>
149 + (integration.pluginId != null &&
150 + i.pluginId == integration.pluginId) ||
151 + (integration.serverUrl != null &&
152 + i.serverUrl == integration.serverUrl),
153 + );
154 if (!exists) {
155 current.add(integration);
156 _update(state.copyWith(savedMcpIntegrations: current));
lib/features/chat/views/components/chat_bubble/assistant_bubble.dart
+9 -5
@@ -52,8 +52,7 @@ class AssistantBubble extends StatelessWidget {
52 Widget build(BuildContext context) {
53 final theme = Theme.of(context);
54 final isDark = theme.brightness == Brightness.dark;
55 - final muted =
56 - isDark ? AppColors.darkMutedText : AppColors.lightMutedText;
55 + final muted = isDark ? AppColors.darkMutedText : AppColors.lightMutedText;
56
57 return Container(
58 width: double.infinity,
@@ -83,6 +82,7 @@ class AssistantBubble extends StatelessWidget {
82 ReasoningWidget(
83 reasoningContent: message.reasoningContent,
84 isStreaming: isStreaming,
85 + hasMainContent: message.content.trim().isNotEmpty,
86 ),
87 if (isStreaming && message.content.isEmpty && message.isProcessing)
88 const ProcessingIndicator()
@@ -100,7 +100,7 @@ class AssistantBubble extends StatelessWidget {
100 color: muted,
101 ),
102 )
103 - else
103 + else if (message.content.trim().isNotEmpty)
104 MarkdownContent(content: message.content, isDark: isDark),
105 if (message.status == MessageStatus.error &&
106 message.errorMessage != null &&
@@ -142,7 +142,11 @@ class AssistantBubble extends StatelessWidget {
142 ),
143 if (message.status == MessageStatus.error) ...[
144 const SizedBox(width: 4),
145 - HugeIcon(icon: HugeIcons.strokeRoundedInformationCircle, size: 14, color: Colors.red[400]),
145 + HugeIcon(
146 + icon: HugeIcons.strokeRoundedInformationCircle,
147 + size: 14,
148 + color: Colors.red[400],
149 + ),
150 ],
151 const Spacer(),
152 if (!isStreaming)
@@ -311,4 +315,4 @@ String _formatTime(DateTime dateTime) {
315 final hour = dateTime.hour.toString().padLeft(2, '0');
316 final minute = dateTime.minute.toString().padLeft(2, '0');
317 return '$hour:$minute';
314 -}
\ No newline at end of file
318 +}
lib/features/chat/views/components/reasoning_widget.dart
+37 -13
@@ -1,33 +1,41 @@
1 import 'package:hugeicons/hugeicons.dart';
2 import 'package:flutter/material.dart';
3 +import 'package:flutter_riverpod/flutter_riverpod.dart';
4 import 'package:gpt_markdown/gpt_markdown.dart';
5 +import 'package:localmind/core/providers/app_providers.dart';
6 import 'package:localmind/l10n/app_localizations.dart';
7 import 'package:localmind/core/theme/colors.dart';
8
7 -class ReasoningWidget extends StatefulWidget {
9 +class ReasoningWidget extends ConsumerStatefulWidget {
10 final String? reasoningContent;
11 final bool isStreaming;
12 + final bool hasMainContent;
13
14 const ReasoningWidget({
15 super.key,
16 this.reasoningContent,
17 this.isStreaming = false,
18 + this.hasMainContent = true,
19 });
20
21 @override
18 - State<ReasoningWidget> createState() => _ReasoningWidgetState();
22 + ConsumerState<ReasoningWidget> createState() => _ReasoningWidgetState();
23 }
24
21 -class _ReasoningWidgetState extends State<ReasoningWidget>
25 +class _ReasoningWidgetState extends ConsumerState<ReasoningWidget>
26 with SingleTickerProviderStateMixin {
27 late bool _isExpanded;
28 + bool _hasUserManuallyToggled = false;
29 late AnimationController _animationController;
30 late Animation<double> _expandAnimation;
31
32 @override
33 void initState() {
34 super.initState();
30 - _isExpanded = widget.isStreaming;
35 + final autoCollapse = ref.read(
36 + settingsProvider.select((s) => s.autoCollapseThinking),
37 + );
38 + _isExpanded = widget.isStreaming || !widget.hasMainContent || !autoCollapse;
39 _animationController = AnimationController(
40 vsync: this,
41 duration: const Duration(milliseconds: 250),
@@ -51,15 +59,31 @@ class _ReasoningWidgetState extends State<ReasoningWidget>
59 void didUpdateWidget(ReasoningWidget oldWidget) {
60 super.didUpdateWidget(oldWidget);
61 if (widget.isStreaming && !oldWidget.isStreaming) {
54 - setState(() {
55 - _isExpanded = true;
56 - _animationController.forward();
57 - });
62 + if (!_isExpanded) {
63 + setState(() {
64 + _isExpanded = true;
65 + _animationController.forward();
66 + });
67 + }
68 + } else if (!widget.isStreaming && oldWidget.isStreaming) {
69 + if (!_hasUserManuallyToggled) {
70 + final autoCollapse = ref.read(
71 + settingsProvider.select((s) => s.autoCollapseThinking),
72 + );
73 + final shouldStayExpanded = !widget.hasMainContent || !autoCollapse;
74 + if (!shouldStayExpanded && _isExpanded) {
75 + setState(() {
76 + _isExpanded = false;
77 + _animationController.reverse();
78 + });
79 + }
80 + }
81 }
82 }
83
84 void _toggleExpand() {
85 setState(() {
86 + _hasUserManuallyToggled = true;
87 _isExpanded = !_isExpanded;
88 if (_isExpanded) {
89 _animationController.forward();
@@ -124,8 +148,8 @@ class _ReasoningWidgetState extends State<ReasoningWidget>
148 ),
149 child: Row(
150 children: [
127 - HugeIcon(icon:
128 - HugeIcons.strokeRoundedBrain,
151 + HugeIcon(
152 + icon: HugeIcons.strokeRoundedBrain,
153 size: 18,
154 color: isDark
155 ? AppColors.darkMutedText
@@ -160,8 +184,8 @@ class _ReasoningWidgetState extends State<ReasoningWidget>
184 begin: 0.0,
185 end: 0.5,
186 ).animate(_expandAnimation),
163 - child: HugeIcon(icon:
164 - HugeIcons.strokeRoundedArrowDown01,
187 + child: HugeIcon(
188 + icon: HugeIcons.strokeRoundedArrowDown01,
189 size: 18,
190 color: isDark
191 ? AppColors.darkMutedText
@@ -204,4 +228,4 @@ class _ReasoningWidgetState extends State<ReasoningWidget>
228 ),
229 );
230 }
207 -}
\ No newline at end of file
231 +}
lib/features/settings/data/models/app_settings.dart
+18 -12
@@ -58,6 +58,7 @@ class AppSettings {
58 final bool roleSwapButtonEnabled;
59 final bool showSystemMessagesInChat;
60 final bool calendarToolsEnabled;
61 + final bool autoCollapseThinking;
62 final List<McpIntegration> savedMcpIntegrations;
63
64 AppSettings({
@@ -106,6 +107,7 @@ class AppSettings {
107 this.roleSwapButtonEnabled = false,
108 this.showSystemMessagesInChat = true,
109 this.calendarToolsEnabled = false,
110 + this.autoCollapseThinking = false,
111 this.savedMcpIntegrations = const [],
112 });
113
@@ -155,6 +157,7 @@ class AppSettings {
157 bool? roleSwapButtonEnabled,
158 bool? showSystemMessagesInChat,
159 bool? calendarToolsEnabled,
160 + bool? autoCollapseThinking,
161 List<McpIntegration>? savedMcpIntegrations,
162 }) {
163 return AppSettings(
@@ -225,10 +228,9 @@ class AppSettings {
228 roleSwapButtonEnabled ?? this.roleSwapButtonEnabled,
229 showSystemMessagesInChat:
230 showSystemMessagesInChat ?? this.showSystemMessagesInChat,
228 - calendarToolsEnabled:
229 - calendarToolsEnabled ?? this.calendarToolsEnabled,
230 - savedMcpIntegrations:
231 - savedMcpIntegrations ?? this.savedMcpIntegrations,
231 + calendarToolsEnabled: calendarToolsEnabled ?? this.calendarToolsEnabled,
232 + autoCollapseThinking: autoCollapseThinking ?? this.autoCollapseThinking,
233 + savedMcpIntegrations: savedMcpIntegrations ?? this.savedMcpIntegrations,
234 );
235 }
236
@@ -279,8 +281,10 @@ class AppSettings {
281 'roleSwapButtonEnabled': roleSwapButtonEnabled,
282 'showSystemMessagesInChat': showSystemMessagesInChat,
283 'calendarToolsEnabled': calendarToolsEnabled,
282 - 'savedMcpIntegrations':
283 - savedMcpIntegrations.map((i) => i.toJson()).toList(),
284 + 'autoCollapseThinking': autoCollapseThinking,
285 + 'savedMcpIntegrations': savedMcpIntegrations
286 + .map((i) => i.toJson())
287 + .toList(),
288 };
289 }
290
@@ -314,8 +318,7 @@ class AppSettings {
318 ttsSpeed: map['ttsSpeed']?.toDouble() ?? 1.0,
319 kittenTtsModelVariant: _parseKittenVariant(map['kittenTtsModelVariant']),
320 autoSpeakEnabled: map['autoSpeakEnabled'] ?? false,
317 - conciseVoiceResponsesEnabled:
318 - map['conciseVoiceResponsesEnabled'] ?? true,
321 + conciseVoiceResponsesEnabled: map['conciseVoiceResponsesEnabled'] ?? true,
322 ttsProcessMarkdown: map['ttsProcessMarkdown'] ?? true,
323 ttsSkipSeconds: _parseTtsSkipSeconds(map['ttsSkipSeconds']),
324 smartReplyEnabled: map['smartReplyEnabled'] ?? true,
@@ -326,15 +329,18 @@ class AppSettings {
329 tempChatKeyboardIncognito: map['tempChatKeyboardIncognito'] ?? true,
330 resumeLastChat: map['resumeLastChat'] ?? true,
331 imageCompressionEnabled: map['imageCompressionEnabled'] ?? false,
329 - imageCompressionLevel:
330 - _parseImageCompressionLevel(map['imageCompressionLevel']),
332 + imageCompressionLevel: _parseImageCompressionLevel(
333 + map['imageCompressionLevel'],
334 + ),
335 smartRepliesUsePersona: map['smartRepliesUsePersona'] ?? false,
336 keepPersonaOnNewChat: map['keepPersonaOnNewChat'] ?? false,
337 roleSwapButtonEnabled: map['roleSwapButtonEnabled'] ?? false,
338 showSystemMessagesInChat: map['showSystemMessagesInChat'] ?? true,
339 calendarToolsEnabled: map['calendarToolsEnabled'] ?? false,
336 - savedMcpIntegrations:
337 - _parseSavedMcpIntegrations(map['savedMcpIntegrations']),
340 + autoCollapseThinking: map['autoCollapseThinking'] ?? false,
341 + savedMcpIntegrations: _parseSavedMcpIntegrations(
342 + map['savedMcpIntegrations'],
343 + ),
344 );
345 }
346
lib/features/settings/views/settings_screen.dart
+8
@@ -264,6 +264,14 @@ class SettingsViews extends ConsumerWidget {
264 .read(settingsProvider.notifier)
265 .setShowSystemMessagesInChat(value),
266 ),
267 + _ToggleSetting(
268 + label: l10n.auto_collapse_thinking,
269 + description: l10n.auto_collapse_thinking_desc,
270 + value: settings.autoCollapseThinking,
271 + onChanged: (value) => ref
272 + .read(settingsProvider.notifier)
273 + .setAutoCollapseThinking(value),
274 + ),
275 _ToggleSetting(
276 label: l10n.haptic_feedback,
277 value: settings.hapticFeedbackEnabled,
lib/l10n/app_ar.arb
+2
@@ -200,6 +200,8 @@
200 "show_system_messages_desc": "عند عدم تحديد أي شخصية، أرسل مطالبة نظام المساعد الافتراضية مع كل طلب",
201 "show_system_messages_in_chat": "إظهار رسائل النظام في الدردشة",
202 "show_system_messages_in_chat_desc": "عرض رسائل النظام (على سبيل المثال من نسخة احتياطية مستوردة) كفقاعات مرئية في المحادثة",
203 + "auto_collapse_thinking": "طي التفكير تلقائيًا",
204 + "auto_collapse_thinking_desc": "طي عملية التفكير تلقائيًا بعد اكتمال التوليد عند وجود إجابة رئيسية",
205 "haptic_feedback": "الاستجابة اللمسية",
206 "enable_mcp": "تفعيل MCP",
207 "new_chat_mcp_default": "تفعيل MCP افتراضياً في المحادثات الجديدة",
lib/l10n/app_bn.arb
+2
@@ -200,6 +200,8 @@
200 "show_system_messages_desc": "যখন কোনো ব্যক্তিত্ব নির্বাচিত না থাকে, তখন প্রতিটি অনুরোধের সাথে একটি ডিফল্ট সহকারী সিস্টেম প্রম্পট পাঠান",
201 "show_system_messages_in_chat": "চ্যাটে সিস্টেম বার্তা দেখান",
202 "show_system_messages_in_chat_desc": "কথোপকথনে দৃশ্যমান বুদবুদ হিসাবে সিস্টেম বার্তাগুলি (যেমন আমদানিকৃত ব্যাকআপ থেকে) প্রদর্শন করুন",
203 + "auto_collapse_thinking": "চিন্তাভাবনা স্বয়ংক্রিয়ভাবে সংকুচিত করুন",
204 + "auto_collapse_thinking_desc": "মূল উত্তর উপস্থিত থাকলে প্রতিক্রিয়া তৈরি সম্পূর্ণ হওয়ার পরে যুক্তির প্রক্রিয়া স্বয়ংক্রিয়ভাবে সংকুচিত করুন",
205 "haptic_feedback": "হ্যাপটিক ফিডব্যাক",
206 "enable_mcp": "MCP সক্ষম করুন",
207 "new_chat_mcp_default": "নতুন চ্যাটে ডিফল্ট MCP",
lib/l10n/app_de.arb
+2
@@ -213,6 +213,8 @@
213 "show_system_messages_desc": "When no persona is selected, send a default assistant system prompt with each request",
214 "show_system_messages_in_chat": "Show System Messages in Chat",
215 "show_system_messages_in_chat_desc": "Display system messages (e.g. from an imported backup) as visible bubbles in the conversation",
216 + "auto_collapse_thinking": "Denkprozess automatisch einklappen",
217 + "auto_collapse_thinking_desc": "Denkprozess nach Abschluss der Antwort automatisch einklappen, wenn eine Hauptantwort vorhanden ist",
218 "haptic_feedback": "Haptic Feedback",
219 "enable_mcp": "Enable MCP",
220 "new_chat_mcp_default": "New Chat MCP Default",
lib/l10n/app_en.arb
+8
@@ -952,6 +952,14 @@
952 "@show_system_messages_in_chat_desc": {
953 "description": "Description for the show-system-messages-in-chat toggle"
954 },
955 + "auto_collapse_thinking": "Auto-collapse Thinking",
956 + "@auto_collapse_thinking": {
957 + "description": "Toggle for automatically collapsing reasoning bubbles after generation completes"
958 + },
959 + "auto_collapse_thinking_desc": "Automatically collapse reasoning process after response generation completes when a main answer is present",
960 + "@auto_collapse_thinking_desc": {
961 + "description": "Description for the auto-collapse thinking toggle"
962 + },
963 "haptic_feedback": "Haptic Feedback",
964 "@haptic_feedback": {
965 "description": "Toggle for haptic feedback"
lib/l10n/app_es.arb
+2
@@ -200,6 +200,8 @@
200 "show_system_messages_desc": "Cuando no se seleccione ningún personaje, enviar un prompt de sistema de asistente predeterminado con cada solicitud",
201 "show_system_messages_in_chat": "Mostrar mensajes del sistema en el chat",
202 "show_system_messages_in_chat_desc": "Mostrar mensajes de sistema (por ejemplo, de una copia de seguridad importada) como burbujas visibles en la conversación",
203 + "auto_collapse_thinking": "Plegar pensamiento automáticamente",
204 + "auto_collapse_thinking_desc": "Plegar automáticamente el proceso de razonamiento tras completar la respuesta cuando hay una respuesta principal",
205 "haptic_feedback": "Comentarios hápticos",
206 "enable_mcp": "Habilitar MCP",
207 "new_chat_mcp_default": "Nuevo chat MCP predeterminado",
lib/l10n/app_fr.arb
+2
@@ -200,6 +200,8 @@
200 "show_system_messages_desc": "When no persona is selected, send a default assistant system prompt with each request",
201 "show_system_messages_in_chat": "Show System Messages in Chat",
202 "show_system_messages_in_chat_desc": "Display system messages (e.g. from an imported backup) as visible bubbles in the conversation",
203 + "auto_collapse_thinking": "Réduire automatiquement la réflexion",
204 + "auto_collapse_thinking_desc": "Réduit automatiquement le raisonnement à la fin de la génération lorsqu'une réponse principale est présente",
205 "haptic_feedback": "Haptic Feedback",
206 "enable_mcp": "Enable MCP",
207 "new_chat_mcp_default": "New Chat MCP Default",
lib/l10n/app_hi.arb
+2
@@ -200,6 +200,8 @@
200 "show_system_messages_desc": "जब कोई व्यक्तित्व चयनित न हो, तो प्रत्येक अनुरोध के साथ एक डिफ़ॉल्ट सहायक सिस्टम प्रॉम्प्ट भेजें",
201 "show_system_messages_in_chat": "चैट में सिस्टम संदेश दिखाएं",
202 "show_system_messages_in_chat_desc": "चैट ट्रांसक्रिप्ट में दृश्यमान बुलबुले के रूप में सिस्टम संदेश (जैसे आयातित बैकअप से) प्रदर्शित करें",
203 + "auto_collapse_thinking": "सोच को स्वचालित रूप से संक्षिप्त करें",
204 + "auto_collapse_thinking_desc": "मुख्य उत्तर मौजूद होने पर प्रतिक्रिया निर्माण पूरा होने के बाद तर्क प्रक्रिया को स्वचालित रूप से संक्षिप्त करें",
205 "haptic_feedback": "हैप्टिक फीडबैक",
206 "enable_mcp": "MCP सक्षम करें",
207 "new_chat_mcp_default": "नई चैट MCP डिफ़ॉल्ट",
lib/l10n/app_it.arb
+2
@@ -200,6 +200,8 @@
200 "show_system_messages_desc": "Quando nessun personaggio è selezionato, invia un prompt di sistema dell'assistente predefinito con ogni richiesta",
201 "show_system_messages_in_chat": "Mostra messaggi di sistema in chat",
202 "show_system_messages_in_chat_desc": "Visualizza i messaggi di sistema (es. da un backup importato) come bolle visibili nella conversazione",
203 + "auto_collapse_thinking": "Comprimi automaticamente pensiero",
204 + "auto_collapse_thinking_desc": "Comprimi automaticamente il ragionamento al termine della risposta quando è presente una risposta principale",
205 "haptic_feedback": "Feedback aptico",
206 "enable_mcp": "Abilita MCP",
207 "new_chat_mcp_default": "Nuova chat MCP predefinita",
lib/l10n/app_ja.arb
+2
@@ -200,6 +200,8 @@
200 "show_system_messages_desc": "ペルソナが選択されていない場合、リクエストごとに関連するデフォルトのアシスタントシステムプロンプトを送信する",
201 "show_system_messages_in_chat": "チャット内にシステムメッセージを表示",
202 "show_system_messages_in_chat_desc": "インポートされたバックアップなどからのシステムメッセージを、チャット画面上に吹き出しとして表示します",
203 + "auto_collapse_thinking": "思考プロセスを自動で折りたたむ",
204 + "auto_collapse_thinking_desc": "メインの回答が存在する場合、生成完了後に推論プロセスを自動で折りたたみます",
205 "haptic_feedback": "触覚フィードバック",
206 "enable_mcp": "MCPを有効化",
207 "new_chat_mcp_default": "新規チャットでMCPをデフォルトにする",
lib/l10n/app_localizations.dart
+12
@@ -1397,6 +1397,18 @@ abstract class AppLocalizations {
1397 /// **'Display system messages (e.g. from an imported backup) as visible bubbles in the conversation'**
1398 String get show_system_messages_in_chat_desc;
1399
1400 + /// Toggle for automatically collapsing reasoning bubbles after generation completes
1401 + ///
1402 + /// In en, this message translates to:
1403 + /// **'Auto-collapse Thinking'**
1404 + String get auto_collapse_thinking;
1405 +
1406 + /// Description for the auto-collapse thinking toggle
1407 + ///
1408 + /// In en, this message translates to:
1409 + /// **'Automatically collapse reasoning process after response generation completes when a main answer is present'**
1410 + String get auto_collapse_thinking_desc;
1411 +
1412 /// Toggle for haptic feedback
1413 ///
1414 /// In en, this message translates to:
lib/l10n/app_localizations_ar.dart
+7
@@ -705,6 +705,13 @@ class AppLocalizationsAr extends AppLocalizations {
705 String get show_system_messages_in_chat_desc =>
706 'عرض رسائل النظام (على سبيل المثال من نسخة احتياطية مستوردة) كفقاعات مرئية في المحادثة';
707
708 + @override
709 + String get auto_collapse_thinking => 'طي التفكير تلقائيًا';
710 +
711 + @override
712 + String get auto_collapse_thinking_desc =>
713 + 'طي عملية التفكير تلقائيًا بعد اكتمال التوليد عند وجود إجابة رئيسية';
714 +
715 @override
716 String get haptic_feedback => 'الاستجابة اللمسية';
717
lib/l10n/app_localizations_bn.dart
+8
@@ -711,6 +711,14 @@ class AppLocalizationsBn extends AppLocalizations {
711 String get show_system_messages_in_chat_desc =>
712 'কথোপকথনে দৃশ্যমান বুদবুদ হিসাবে সিস্টেম বার্তাগুলি (যেমন আমদানিকৃত ব্যাকআপ থেকে) প্রদর্শন করুন';
713
714 + @override
715 + String get auto_collapse_thinking =>
716 + 'চিন্তাভাবনা স্বয়ংক্রিয়ভাবে সংকুচিত করুন';
717 +
718 + @override
719 + String get auto_collapse_thinking_desc =>
720 + 'মূল উত্তর উপস্থিত থাকলে প্রতিক্রিয়া তৈরি সম্পূর্ণ হওয়ার পরে যুক্তির প্রক্রিয়া স্বয়ংক্রিয়ভাবে সংকুচিত করুন';
721 +
722 @override
723 String get haptic_feedback => 'হ্যাপটিক ফিডব্যাক';
724
lib/l10n/app_localizations_de.dart
+7
@@ -705,6 +705,13 @@ class AppLocalizationsDe extends AppLocalizations {
705 String get show_system_messages_in_chat_desc =>
706 'Display system messages (e.g. from an imported backup) as visible bubbles in the conversation';
707
708 + @override
709 + String get auto_collapse_thinking => 'Denkprozess automatisch einklappen';
710 +
711 + @override
712 + String get auto_collapse_thinking_desc =>
713 + 'Denkprozess nach Abschluss der Antwort automatisch einklappen, wenn eine Hauptantwort vorhanden ist';
714 +
715 @override
716 String get haptic_feedback => 'Haptic Feedback';
717
lib/l10n/app_localizations_en.dart
+7
@@ -699,6 +699,13 @@ class AppLocalizationsEn extends AppLocalizations {
699 String get show_system_messages_in_chat_desc =>
700 'Display system messages (e.g. from an imported backup) as visible bubbles in the conversation';
701
702 + @override
703 + String get auto_collapse_thinking => 'Auto-collapse Thinking';
704 +
705 + @override
706 + String get auto_collapse_thinking_desc =>
707 + 'Automatically collapse reasoning process after response generation completes when a main answer is present';
708 +
709 @override
710 String get haptic_feedback => 'Haptic Feedback';
711
lib/l10n/app_localizations_es.dart
+7
@@ -711,6 +711,13 @@ class AppLocalizationsEs extends AppLocalizations {
711 String get show_system_messages_in_chat_desc =>
712 'Mostrar mensajes de sistema (por ejemplo, de una copia de seguridad importada) como burbujas visibles en la conversación';
713
714 + @override
715 + String get auto_collapse_thinking => 'Plegar pensamiento automáticamente';
716 +
717 + @override
718 + String get auto_collapse_thinking_desc =>
719 + 'Plegar automáticamente el proceso de razonamiento tras completar la respuesta cuando hay una respuesta principal';
720 +
721 @override
722 String get haptic_feedback => 'Comentarios hápticos';
723
lib/l10n/app_localizations_fr.dart
+7
@@ -706,6 +706,13 @@ class AppLocalizationsFr extends AppLocalizations {
706 String get show_system_messages_in_chat_desc =>
707 'Display system messages (e.g. from an imported backup) as visible bubbles in the conversation';
708
709 + @override
710 + String get auto_collapse_thinking => 'Réduire automatiquement la réflexion';
711 +
712 + @override
713 + String get auto_collapse_thinking_desc =>
714 + 'Réduit automatiquement le raisonnement à la fin de la génération lorsqu\'une réponse principale est présente';
715 +
716 @override
717 String get haptic_feedback => 'Haptic Feedback';
718
lib/l10n/app_localizations_hi.dart
+7
@@ -712,6 +712,13 @@ class AppLocalizationsHi extends AppLocalizations {
712 String get show_system_messages_in_chat_desc =>
713 'चैट ट्रांसक्रिप्ट में दृश्यमान बुलबुले के रूप में सिस्टम संदेश (जैसे आयातित बैकअप से) प्रदर्शित करें';
714
715 + @override
716 + String get auto_collapse_thinking => 'सोच को स्वचालित रूप से संक्षिप्त करें';
717 +
718 + @override
719 + String get auto_collapse_thinking_desc =>
720 + 'मुख्य उत्तर मौजूद होने पर प्रतिक्रिया निर्माण पूरा होने के बाद तर्क प्रक्रिया को स्वचालित रूप से संक्षिप्त करें';
721 +
722 @override
723 String get haptic_feedback => 'हैप्टिक फीडबैक';
724
lib/l10n/app_localizations_it.dart
+7
@@ -712,6 +712,13 @@ class AppLocalizationsIt extends AppLocalizations {
712 String get show_system_messages_in_chat_desc =>
713 'Visualizza i messaggi di sistema (es. da un backup importato) come bolle visibili nella conversazione';
714
715 + @override
716 + String get auto_collapse_thinking => 'Comprimi automaticamente pensiero';
717 +
718 + @override
719 + String get auto_collapse_thinking_desc =>
720 + 'Comprimi automaticamente il ragionamento al termine della risposta quando è presente una risposta principale';
721 +
722 @override
723 String get haptic_feedback => 'Feedback aptico';
724
lib/l10n/app_localizations_ja.dart
+7
@@ -693,6 +693,13 @@ class AppLocalizationsJa extends AppLocalizations {
693 String get show_system_messages_in_chat_desc =>
694 'インポートされたバックアップなどからのシステムメッセージを、チャット画面上に吹き出しとして表示します';
695
696 + @override
697 + String get auto_collapse_thinking => '思考プロセスを自動で折りたたむ';
698 +
699 + @override
700 + String get auto_collapse_thinking_desc =>
701 + 'メインの回答が存在する場合、生成完了後に推論プロセスを自動で折りたたみます';
702 +
703 @override
704 String get haptic_feedback => '触覚フィードバック';
705
lib/l10n/app_localizations_ru.dart
+7
@@ -711,6 +711,13 @@ class AppLocalizationsRu extends AppLocalizations {
711 String get show_system_messages_in_chat_desc =>
712 'Отображать системные сообщения (например, из импортированной резервной копии) в виде облачков сообщений в чате';
713
714 + @override
715 + String get auto_collapse_thinking => 'Автосворачивание рассуждений';
716 +
717 + @override
718 + String get auto_collapse_thinking_desc =>
719 + 'Автоматически сворачивать блок рассуждений после завершения генерации, если присутствует основной ответ';
720 +
721 @override
722 String get haptic_feedback => 'Тактильная обратная связь';
723
lib/l10n/app_localizations_tr.dart
+7
@@ -711,6 +711,13 @@ class AppLocalizationsTr extends AppLocalizations {
711 String get show_system_messages_in_chat_desc =>
712 'Sistem mesajlarını (örn. içe aktarılan yedekten) konuşmada görünür baloncuklar olarak göster';
713
714 + @override
715 + String get auto_collapse_thinking => 'Düşünme Sürecini Otomatik Daralt';
716 +
717 + @override
718 + String get auto_collapse_thinking_desc =>
719 + 'Ana yanıt mevcut olduğunda oluşturma tamamlandıktan sonra akıl yürütme sürecini otomatik olarak daralt';
720 +
721 @override
722 String get haptic_feedback => 'Dokunsal Geri Bildirim';
723
lib/l10n/app_localizations_zh.dart
+12
@@ -686,6 +686,12 @@ class AppLocalizationsZh extends AppLocalizations {
686 String get show_system_messages_in_chat_desc =>
687 '将系统消息 (例如来自导入的备份) 作为对话中的可见气泡显示';
688
689 + @override
690 + String get auto_collapse_thinking => '自动折叠思考过程';
691 +
692 + @override
693 + String get auto_collapse_thinking_desc => '存在主要回复时,在生成完成后自动折叠推理过程';
694 +
695 @override
696 String get haptic_feedback => '触觉反馈';
697
@@ -3634,6 +3640,12 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
3640 String get show_system_messages_in_chat_desc =>
3641 '將系統消息 (例如來自導入的備份) 作為對話中的可見氣泡顯示';
3642
3643 + @override
3644 + String get auto_collapse_thinking => '自動摺疊思考過程';
3645 +
3646 + @override
3647 + String get auto_collapse_thinking_desc => '存在主要回覆時,在生成完成後自動摺疊推理過程';
3648 +
3649 @override
3650 String get haptic_feedback => '觸覺回饋';
3651
lib/l10n/app_ru.arb
+2
@@ -200,6 +200,8 @@
200 "show_system_messages_desc": "Если персонаж не выбран, отправлять стандартный системный промпт ассистента с каждым запросом",
201 "show_system_messages_in_chat": "Показывать системные сообщения в чате",
202 "show_system_messages_in_chat_desc": "Отображать системные сообщения (например, из импортированной резервной копии) в виде облачков сообщений в чате",
203 + "auto_collapse_thinking": "Автосворачивание рассуждений",
204 + "auto_collapse_thinking_desc": "Автоматически сворачивать блок рассуждений после завершения генерации, если присутствует основной ответ",
205 "haptic_feedback": "Тактильная обратная связь",
206 "enable_mcp": "Включить MCP",
207 "new_chat_mcp_default": "Новый чат MCP по умолчанию",
lib/l10n/app_tr.arb
+2
@@ -200,6 +200,8 @@
200 "show_system_messages_desc": "Persona seçilmediğinde, her istekle birlikte varsayılan asistan sistem yönergesini gönder",
201 "show_system_messages_in_chat": "Sohbette Sistem Mesajlarını Göster",
202 "show_system_messages_in_chat_desc": "Sistem mesajlarını (örn. içe aktarılan yedekten) konuşmada görünür baloncuklar olarak göster",
203 + "auto_collapse_thinking": "Düşünme Sürecini Otomatik Daralt",
204 + "auto_collapse_thinking_desc": "Ana yanıt mevcut olduğunda oluşturma tamamlandıktan sonra akıl yürütme sürecini otomatik olarak daralt",
205 "haptic_feedback": "Dokunsal Geri Bildirim",
206 "enable_mcp": "MCP'yi Etkinleştir",
207 "new_chat_mcp_default": "Yeni Sohbet MCP Varsayılanı",
lib/l10n/app_zh.arb
+2
@@ -200,6 +200,8 @@
200 "show_system_messages_desc": "当没有选定角色时,每次请求都发送默认的助手系统提示词",
201 "show_system_messages_in_chat": "在聊天中显示系统消息",
202 "show_system_messages_in_chat_desc": "将系统消息 (例如来自导入的备份) 作为对话中的可见气泡显示",
203 + "auto_collapse_thinking": "自动折叠思考过程",
204 + "auto_collapse_thinking_desc": "存在主要回复时,在生成完成后自动折叠推理过程",
205 "haptic_feedback": "触觉反馈",
206 "enable_mcp": "启用 MCP",
207 "new_chat_mcp_default": "新聊天默认启用 MCP",
lib/l10n/app_zh_TW.arb
+2
@@ -200,6 +200,8 @@
200 "show_system_messages_desc": "當沒有選定角色時,每次請求都發送預設的助手系統提示詞",
201 "show_system_messages_in_chat": "在聊天中顯示系統消息",
202 "show_system_messages_in_chat_desc": "將系統消息 (例如來自導入的備份) 作為對話中的可見氣泡顯示",
203 + "auto_collapse_thinking": "自動摺疊思考過程",
204 + "auto_collapse_thinking_desc": "存在主要回覆時,在生成完成後自動摺疊推理過程",
205 "haptic_feedback": "觸覺回饋",
206 "enable_mcp": "啟用 MCP",
207 "new_chat_mcp_default": "新對話預設啟用 MCP",
test/features/chat/reasoning_widget_test.dart new
+295
@@ -0,0 +1,295 @@
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:gpt_markdown/gpt_markdown.dart';
5 +import 'package:localmind/core/providers/app_providers.dart';
6 +import 'package:localmind/features/chat/views/components/reasoning_widget.dart';
7 +import 'package:localmind/features/settings/data/models/app_settings.dart';
8 +import 'package:localmind/l10n/app_localizations.dart';
9 +
10 +Widget _buildTestWidget({
11 + required String reasoningContent,
12 + bool isStreaming = false,
13 + bool hasMainContent = true,
14 + AppSettings? settings,
15 +}) {
16 + final appSettings = settings ?? AppSettings();
17 + return ProviderScope(
18 + overrides: [
19 + settingsProvider.overrideWith(() => _MockSettingsNotifier(appSettings)),
20 + ],
21 + child: MaterialApp(
22 + localizationsDelegates: AppLocalizations.localizationsDelegates,
23 + supportedLocales: AppLocalizations.supportedLocales,
24 + home: Scaffold(
25 + body: SingleChildScrollView(
26 + child: ReasoningWidget(
27 + reasoningContent: reasoningContent,
28 + isStreaming: isStreaming,
29 + hasMainContent: hasMainContent,
30 + ),
31 + ),
32 + ),
33 + ),
34 + );
35 +}
36 +
37 +class _MockSettingsNotifier extends SettingsNotifier {
38 + final AppSettings _initial;
39 + _MockSettingsNotifier(this._initial);
40 +
41 + @override
42 + AppSettings build() => _initial;
43 +}
44 +
45 +void main() {
46 + group('ReasoningWidget Tests', () {
47 + testWidgets(
48 + 'remains expanded when hasMainContent is false (no main response)',
49 + (tester) async {
50 + await tester.pumpWidget(
51 + _buildTestWidget(
52 + reasoningContent: 'Thinking about the problem...',
53 + isStreaming: false,
54 + hasMainContent: false,
55 + ),
56 + );
57 + await tester.pumpAndSettle();
58 +
59 + expect(find.byType(GptMarkdown), findsOneWidget);
60 + expect(find.text('Thinking about the problem...'), findsOneWidget);
61 +
62 + final sizeTransition = tester.widget<SizeTransition>(
63 + find.byType(SizeTransition),
64 + );
65 + expect(sizeTransition.sizeFactor.value, 1.0);
66 + },
67 + );
68 +
69 + testWidgets(
70 + 'remains expanded after streaming if autoCollapseThinking is false',
71 + (tester) async {
72 + await tester.pumpWidget(
73 + _buildTestWidget(
74 + reasoningContent: 'Step-by-step reasoning',
75 + isStreaming: false,
76 + hasMainContent: true,
77 + settings: AppSettings(autoCollapseThinking: false),
78 + ),
79 + );
80 + await tester.pumpAndSettle();
81 +
82 + final sizeTransition = tester.widget<SizeTransition>(
83 + find.byType(SizeTransition),
84 + );
85 + expect(sizeTransition.sizeFactor.value, 1.0);
86 + expect(find.text('Step-by-step reasoning'), findsOneWidget);
87 + },
88 + );
89 +
90 + testWidgets(
91 + 'default settings keep the bubble expanded after generation (issue #28)',
92 + (tester) async {
93 + // No settings override: relies on the default value of autoCollapseThinking.
94 + await tester.pumpWidget(
95 + _buildTestWidget(
96 + reasoningContent: 'The answer lived inside the thinking block',
97 + isStreaming: false,
98 + hasMainContent: true,
99 + ),
100 + );
101 + await tester.pumpAndSettle();
102 +
103 + final sizeTransition = tester.widget<SizeTransition>(
104 + find.byType(SizeTransition),
105 + );
106 + expect(sizeTransition.sizeFactor.value, 1.0);
107 + expect(
108 + find.text('The answer lived inside the thinking block'),
109 + findsOneWidget,
110 + );
111 + },
112 + );
113 +
114 + testWidgets('is expanded while isStreaming is true', (tester) async {
115 + await tester.pumpWidget(
116 + _buildTestWidget(
117 + reasoningContent: 'Currently thinking live...',
118 + isStreaming: true,
119 + hasMainContent: true,
120 + ),
121 + );
122 + await tester.pump(const Duration(milliseconds: 300));
123 +
124 + final sizeTransition = tester.widget<SizeTransition>(
125 + find.byType(SizeTransition),
126 + );
127 + expect(sizeTransition.sizeFactor.value, 1.0);
128 + expect(find.byType(CircularProgressIndicator), findsOneWidget);
129 + });
130 +
131 + testWidgets(
132 + 'collapses when streaming ends if hasMainContent is true and autoCollapseThinking is true',
133 + (tester) async {
134 + await tester.pumpWidget(
135 + _buildTestWidget(
136 + reasoningContent: 'Finished thinking',
137 + isStreaming: false,
138 + hasMainContent: true,
139 + settings: AppSettings(autoCollapseThinking: true),
140 + ),
141 + );
142 + await tester.pumpAndSettle();
143 +
144 + final sizeTransition = tester.widget<SizeTransition>(
145 + find.byType(SizeTransition),
146 + );
147 + expect(sizeTransition.sizeFactor.value, 0.0);
148 + },
149 + );
150 +
151 + testWidgets('toggles expanded state on tap', (tester) async {
152 + await tester.pumpWidget(
153 + _buildTestWidget(
154 + reasoningContent: 'Click to see details',
155 + isStreaming: false,
156 + hasMainContent: true,
157 + settings: AppSettings(autoCollapseThinking: true),
158 + ),
159 + );
160 + await tester.pumpAndSettle();
161 +
162 + // Initially collapsed
163 + var sizeTransition = tester.widget<SizeTransition>(
164 + find.byType(SizeTransition),
165 + );
166 + expect(sizeTransition.sizeFactor.value, 0.0);
167 +
168 + // Tap header to expand
169 + await tester.tap(find.byType(InkWell));
170 + await tester.pumpAndSettle();
171 +
172 + sizeTransition = tester.widget<SizeTransition>(
173 + find.byType(SizeTransition),
174 + );
175 + expect(sizeTransition.sizeFactor.value, 1.0);
176 +
177 + // Tap header to collapse again
178 + await tester.tap(find.byType(InkWell));
179 + await tester.pumpAndSettle();
180 +
181 + sizeTransition = tester.widget<SizeTransition>(
182 + find.byType(SizeTransition),
183 + );
184 + expect(sizeTransition.sizeFactor.value, 0.0);
185 + });
186 +
187 + testWidgets(
188 + 'transitions from streaming to non-streaming: remains expanded if hasMainContent is false',
189 + (tester) async {
190 + await tester.pumpWidget(
191 + _buildTestWidget(
192 + reasoningContent: 'Full answer in reasoning block',
193 + isStreaming: true,
194 + hasMainContent: false,
195 + settings: AppSettings(autoCollapseThinking: true),
196 + ),
197 + );
198 + await tester.pump(const Duration(milliseconds: 300));
199 +
200 + // Stream ends, rebuild with isStreaming: false
201 + await tester.pumpWidget(
202 + _buildTestWidget(
203 + reasoningContent: 'Full answer in reasoning block',
204 + isStreaming: false,
205 + hasMainContent: false,
206 + settings: AppSettings(autoCollapseThinking: true),
207 + ),
208 + );
209 + await tester.pumpAndSettle();
210 +
211 + final sizeTransition = tester.widget<SizeTransition>(
212 + find.byType(SizeTransition),
213 + );
214 + expect(sizeTransition.sizeFactor.value, 1.0);
215 + },
216 + );
217 +
218 + testWidgets(
219 + 'transitions from streaming to non-streaming: collapses if hasMainContent is true and autoCollapseThinking is true',
220 + (tester) async {
221 + await tester.pumpWidget(
222 + _buildTestWidget(
223 + reasoningContent: 'Intermediary reasoning',
224 + isStreaming: true,
225 + hasMainContent: true,
226 + settings: AppSettings(autoCollapseThinking: true),
227 + ),
228 + );
229 + await tester.pump(const Duration(milliseconds: 300));
230 +
231 + var sizeTransition = tester.widget<SizeTransition>(
232 + find.byType(SizeTransition),
233 + );
234 + expect(sizeTransition.sizeFactor.value, 1.0);
235 +
236 + // Stream ends, rebuild with isStreaming: false and hasMainContent: true
237 + await tester.pumpWidget(
238 + _buildTestWidget(
239 + reasoningContent: 'Intermediary reasoning',
240 + isStreaming: false,
241 + hasMainContent: true,
242 + settings: AppSettings(autoCollapseThinking: true),
243 + ),
244 + );
245 + await tester.pumpAndSettle();
246 +
247 + sizeTransition = tester.widget<SizeTransition>(
248 + find.byType(SizeTransition),
249 + );
250 + expect(sizeTransition.sizeFactor.value, 0.0);
251 + },
252 + );
253 +
254 + testWidgets(
255 + 'transitions from streaming to non-streaming: respects manual expand/collapse toggle',
256 + (tester) async {
257 + await tester.pumpWidget(
258 + _buildTestWidget(
259 + reasoningContent: 'Streaming reasoning...',
260 + isStreaming: true,
261 + hasMainContent: true,
262 + settings: AppSettings(autoCollapseThinking: true),
263 + ),
264 + );
265 + await tester.pump(const Duration(milliseconds: 300));
266 +
267 + // User manually collapses while streaming
268 + await tester.tap(find.byType(InkWell));
269 + await tester.pump();
270 + await tester.pump(const Duration(milliseconds: 300));
271 +
272 + var sizeTransition = tester.widget<SizeTransition>(
273 + find.byType(SizeTransition),
274 + );
275 + expect(sizeTransition.sizeFactor.value, 0.0);
276 +
277 + // Stream finishes: since user manually collapsed, it stays collapsed
278 + await tester.pumpWidget(
279 + _buildTestWidget(
280 + reasoningContent: 'Streaming reasoning...',
281 + isStreaming: false,
282 + hasMainContent: true,
283 + settings: AppSettings(autoCollapseThinking: true),
284 + ),
285 + );
286 + await tester.pumpAndSettle();
287 +
288 + sizeTransition = tester.widget<SizeTransition>(
289 + find.byType(SizeTransition),
290 + );
291 + expect(sizeTransition.sizeFactor.value, 0.0);
292 + },
293 + );
294 + });
295 +}