4 files changed
+145
-10
lib/features/chat/views/components/chat_bubble/assistant_bubble.dart
+5
-1
@@ -231,7 +231,11 @@ class _StreamingContentState extends State<_StreamingContent> {
231
232
@override
233
Widget build(BuildContext context) {
234
- return MarkdownBodyContent(content: _visibleContent, isDark: widget.isDark);
234
+ return MarkdownBodyContent(
235
+ content: _visibleContent,
236
+ isDark: widget.isDark,
237
+ selectable: false,
238
+ );
239
}
240
}
241
lib/features/chat/views/components/chat_bubble/markdown/themed_gpt_markdown.dart
+20
-9
@@ -157,28 +157,39 @@ class MarkdownContent extends StatelessWidget {
157
}
158
}
159
160
+/// Renders formatted markdown body content.
161
+///
162
+/// Set [selectable] to false during active streaming to prevent Flutter
163
+/// SelectionContainer concurrent modification crashes while the widget
164
+/// tree is rapidly mutating (Issue #80).
165
class MarkdownBodyContent extends StatelessWidget {
166
const MarkdownBodyContent({
167
super.key,
168
required this.content,
169
required this.isDark,
170
+ this.selectable = true,
171
});
172
173
final String content;
174
final bool isDark;
175
+ final bool selectable;
176
177
@override
178
Widget build(BuildContext context) {
172
- return SelectionArea(
173
- child: ThemedGptMarkdown(
174
- content: content,
175
- isDark: isDark,
176
- style: TextStyle(
177
- color: isDark ? Colors.white : Colors.black,
178
- fontSize: 15,
179
- height: 1.5,
180
- ),
179
+ final markdown = ThemedGptMarkdown(
180
+ content: content,
181
+ isDark: isDark,
182
+ style: TextStyle(
183
+ color: isDark ? Colors.white : Colors.black,
184
+ fontSize: 15,
185
+ height: 1.5,
186
),
187
);
188
+
189
+ if (!selectable) {
190
+ return markdown;
191
+ }
192
+
193
+ return SelectionArea(child: markdown);
194
}
195
}
test/features/chat/assistant_bubble_test.dart
new
+80
@@ -0,0 +1,80 @@
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/models/enums.dart';
5
+import 'package:localmind/core/providers/storage_providers.dart';
6
+import 'package:localmind/features/chat/data/models/message.dart';
7
+import 'package:localmind/features/chat/views/components/chat_bubble/assistant_bubble.dart';
8
+import 'package:localmind/l10n/app_localizations.dart';
9
+import 'package:shared_preferences/shared_preferences.dart';
10
+
11
+void main() {
12
+ setUp(() {
13
+ SharedPreferences.setMockInitialValues({});
14
+ });
15
+
16
+ Widget buildHarness({
17
+ required SharedPreferences prefs,
18
+ required Message message,
19
+ required bool isStreaming,
20
+ }) {
21
+ return ProviderScope(
22
+ overrides: [sharedPreferencesProvider.overrideWithValue(prefs)],
23
+ child: MaterialApp(
24
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
25
+ supportedLocales: AppLocalizations.supportedLocales,
26
+ home: Scaffold(
27
+ body: AssistantBubble(message: message, isStreaming: isStreaming),
28
+ ),
29
+ ),
30
+ );
31
+ }
32
+
33
+ testWidgets(
34
+ 'AssistantBubble streaming content disables SelectionArea to prevent concurrent modification crash',
35
+ (tester) async {
36
+ final prefs = await SharedPreferences.getInstance();
37
+ final message = Message(
38
+ id: 'msg-stream',
39
+ conversationId: 'conv-1',
40
+ role: MessageRole.assistant,
41
+ content: 'Streaming response token by token...',
42
+ createdAt: DateTime.now(),
43
+ status: MessageStatus.streaming,
44
+ );
45
+
46
+ await tester.pumpWidget(
47
+ buildHarness(prefs: prefs, message: message, isStreaming: true),
48
+ );
49
+ await tester.pump();
50
+
51
+ // When streaming, SelectionArea must not wrap the live-mutating markdown
52
+ expect(find.byType(SelectionArea), findsNothing);
53
+ expect(find.text('Streaming response token by token...'), findsOneWidget);
54
+ },
55
+ );
56
+
57
+ testWidgets(
58
+ 'AssistantBubble completed content enables SelectionArea for copy and selection',
59
+ (tester) async {
60
+ final prefs = await SharedPreferences.getInstance();
61
+ final message = Message(
62
+ id: 'msg-done',
63
+ conversationId: 'conv-1',
64
+ role: MessageRole.assistant,
65
+ content: 'Final completed response.',
66
+ createdAt: DateTime.now(),
67
+ status: MessageStatus.complete,
68
+ );
69
+
70
+ await tester.pumpWidget(
71
+ buildHarness(prefs: prefs, message: message, isStreaming: false),
72
+ );
73
+ await tester.pump();
74
+
75
+ // When completed, SelectionArea should wrap the markdown content
76
+ expect(find.byType(SelectionArea), findsOneWidget);
77
+ expect(find.text('Final completed response.'), findsOneWidget);
78
+ },
79
+ );
80
+}
test/features/chat/themed_gpt_markdown_test.dart
+40
@@ -24,4 +24,44 @@ void main() {
24
.whereType<NetworkImage>();
25
expect(networkImages.map((image) => image.url), contains(url));
26
});
27
+
28
+ testWidgets(
29
+ 'MarkdownBodyContent wraps with SelectionArea when selectable is true',
30
+ (tester) async {
31
+ await tester.pumpWidget(
32
+ const MaterialApp(
33
+ home: Scaffold(
34
+ body: MarkdownBodyContent(
35
+ content: 'Hello world',
36
+ isDark: false,
37
+ selectable: true,
38
+ ),
39
+ ),
40
+ ),
41
+ );
42
+
43
+ expect(find.byType(SelectionArea), findsOneWidget);
44
+ expect(find.text('Hello world'), findsOneWidget);
45
+ },
46
+ );
47
+
48
+ testWidgets(
49
+ 'MarkdownBodyContent does not wrap with SelectionArea when selectable is false',
50
+ (tester) async {
51
+ await tester.pumpWidget(
52
+ const MaterialApp(
53
+ home: Scaffold(
54
+ body: MarkdownBodyContent(
55
+ content: 'Streaming content',
56
+ isDark: false,
57
+ selectable: false,
58
+ ),
59
+ ),
60
+ ),
61
+ );
62
+
63
+ expect(find.byType(SelectionArea), findsNothing);
64
+ expect(find.text('Streaming content'), findsOneWidget);
65
+ },
66
+ );
67
}