@tduncan / Cortana / commits / 3ace436

fix #80: disable SelectionArea during streaming to prevent concurrent modification crashes

abdulmominsakib committed Sep 14, 2026 at 11:04 UTC 3ace4369508e28fada42dae357f8fc63098cbe30
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 }