Skip to content

Commit cf61642

Browse files
authored
fix(desktop): enable text selection in chat thinking lines and inline code (#58)
* fix(desktop): enable text selection in chat thinking lines and inline code * fix(desktop): make thinking icon a tap target to collapse when expanded Semantics(onTap:) only exposes an accessibility action; it does not install a real pointer handler, so tapping the icon/padding of an expanded thinking line did nothing. Wrap the leading icon in an opaque GestureDetector so the row-level collapse intent holds for pointer users while SelectableText keeps its drag-selection behavior. * test(desktop): cover thinking line interaction contract Add widget tests for the collapsed/expanded thinking line: collapsed renders a single-line ellipsized Text, tapping expands to a SelectableText, the expanded row exposes a 'Collapse thinking' semantics action, and tapping the leading icon collapses again.
1 parent d27f5f2 commit cf61642

3 files changed

Lines changed: 167 additions & 32 deletions

File tree

app/lib/desktop/chat/desktop_chat_pane.dart

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -421,30 +421,51 @@ class _ThinkingLineState extends State<_ThinkingLine> {
421421
fontStyle: FontStyle.italic,
422422
height: 1.3,
423423
);
424-
return InkWell(
425-
onTap: () => setState(() => _expanded = !_expanded),
426-
child: Padding(
427-
padding: const EdgeInsets.symmetric(vertical: 6),
428-
child: Row(
429-
crossAxisAlignment: CrossAxisAlignment.start,
430-
children: [
431-
Icon(
432-
Symbols.psychology,
433-
weight: 200,
434-
size: 15,
435-
color: cs.onSurfaceVariant.withValues(alpha: 0.55),
436-
),
437-
const SizedBox(width: 6),
438-
Expanded(
439-
child: Text(
440-
widget.text.trim(),
441-
style: style,
442-
maxLines: _expanded ? null : 1,
443-
overflow: _expanded ? TextOverflow.clip : TextOverflow.ellipsis,
444-
),
445-
),
446-
],
447-
),
424+
void toggle() => setState(() => _expanded = !_expanded);
425+
final textWidget = _expanded
426+
? SelectableText(widget.text.trim(), style: style, onTap: toggle)
427+
: Text(
428+
widget.text.trim(),
429+
style: style,
430+
maxLines: 1,
431+
overflow: TextOverflow.ellipsis,
432+
);
433+
// Expanded: SelectableText handles taps on the text for selection; a tap
434+
// on the leading icon collapses. Collapsed: whole row is a tap target that
435+
// expands.
436+
return _expanded
437+
? Semantics(
438+
onTap: toggle,
439+
onTapHint: 'Collapse thinking',
440+
child: _buildRow(textWidget, onLeadingTap: toggle),
441+
)
442+
: InkWell(onTap: toggle, child: _buildRow(textWidget));
443+
}
444+
445+
Widget _buildRow(Widget textWidget, {VoidCallback? onLeadingTap}) {
446+
final cs = Theme.of(context).colorScheme;
447+
Widget leading = Icon(
448+
Symbols.psychology,
449+
weight: 200,
450+
size: 15,
451+
color: cs.onSurfaceVariant.withValues(alpha: 0.55),
452+
);
453+
if (onLeadingTap != null) {
454+
leading = GestureDetector(
455+
behavior: HitTestBehavior.opaque,
456+
onTap: onLeadingTap,
457+
child: leading,
458+
);
459+
}
460+
return Padding(
461+
padding: const EdgeInsets.symmetric(vertical: 6),
462+
child: Row(
463+
crossAxisAlignment: CrossAxisAlignment.start,
464+
children: [
465+
leading,
466+
const SizedBox(width: 6),
467+
Expanded(child: textWidget),
468+
],
448469
),
449470
);
450471
}

app/lib/ui/session/chat_message.dart

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,8 @@ Future<void> _openLink(String text, String? href, String title) async {
118118
}
119119

120120
/// Renders fenced code blocks with syntax highlighting + a copy button.
121-
/// Inline `code` returns null → falls back to the stylesheet's inline style.
121+
/// Inline `code` returns a Container with background (not TextStyle.backgroundColor)
122+
/// so text selection highlight renders on top.
122123
class _CodeBlockBuilder extends MarkdownElementBuilder {
123124
_CodeBlockBuilder(this.context);
124125
final BuildContext context;
@@ -133,8 +134,29 @@ class _CodeBlockBuilder extends MarkdownElementBuilder {
133134
var code = element.textContent;
134135
if (code.endsWith('\n')) code = code.substring(0, code.length - 1);
135136
final isBlock = className != null || code.contains('\n');
136-
if (!isBlock) return null; // inline code
137-
return _CodeBlock(code: code, language: language);
137+
138+
if (isBlock) {
139+
return _CodeBlock(code: code, language: language);
140+
}
141+
142+
// Inline code - wrap in Container with background for proper selection rendering
143+
final dark = Theme.of(context).brightness == Brightness.dark;
144+
final bg = dark ? const Color(0xFF33363E) : const Color(0xFFEBECF0);
145+
return Container(
146+
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
147+
decoration: BoxDecoration(
148+
color: bg,
149+
borderRadius: BorderRadius.circular(4),
150+
),
151+
child: Text(
152+
code,
153+
style: preferredStyle?.copyWith(
154+
fontFamily: 'monospace',
155+
fontSize: 13,
156+
backgroundColor: Colors.transparent,
157+
),
158+
),
159+
);
138160
}
139161
}
140162

@@ -214,22 +236,20 @@ class _CopyButtonState extends State<_CopyButton> {
214236
MarkdownStyleSheet _styleSheet(BuildContext context) {
215237
final theme = Theme.of(context);
216238
final cs = theme.colorScheme;
217-
final dark = theme.brightness == Brightness.dark;
239+
// Inline `code`: mono font. Background is applied by _CodeBlockBuilder
240+
// to allow text selection to render on top.
218241
final mono = theme.textTheme.bodyMedium?.copyWith(
219242
fontFamily: 'monospace',
220243
fontSize: 13,
221244
);
222-
// Inline `code`: mono font on a subtle-but-visible background, tuned per
223-
// theme (a light grey on light mode, a lifted grey on dark mode).
224-
final inlineCodeBg = dark ? const Color(0xFF33363E) : const Color(0xFFEBECF0);
225245
// LLMs love emitting h1/h2/h3 headers; render them all as plain bold text at
226246
// the normal body size instead of oversized headings.
227247
final heading = theme.textTheme.bodyMedium?.copyWith(
228248
fontWeight: FontWeight.w700,
229249
);
230250
return MarkdownStyleSheet.fromTheme(theme).copyWith(
231251
p: theme.textTheme.bodyMedium,
232-
code: mono?.copyWith(backgroundColor: inlineCodeBg),
252+
code: mono,
233253
h1: heading,
234254
h2: heading,
235255
h3: heading,

app/test/desktop/desktop_chat_pane_test.dart

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,107 @@ import 'package:flutter/services.dart';
33
import 'package:flutter_riverpod/flutter_riverpod.dart';
44
import 'package:flutter_test/flutter_test.dart';
55
import 'package:makit/desktop/chat/desktop_chat_pane.dart';
6+
import 'package:material_symbols_icons/symbols.dart';
67
import 'package:makit/desktop/chat/selected_session.dart';
78
import 'package:makit/desktop/chat/sidebar_layout.dart';
89
import 'package:makit/store/models.dart';
910
import 'package:makit/store/store.dart';
1011
import 'package:makit/ui/home/repo_chips.dart';
1112

13+
Session _session() => Session(
14+
id: 's1',
15+
projectId: 'p1',
16+
agent: 'pi',
17+
title: 'Test session',
18+
status: SessionStatus.idle,
19+
policy: ApprovalPolicy.askOnRisky,
20+
lastPreview: '',
21+
lastActivityAt: 0,
22+
);
23+
24+
ProviderContainer _thinkingContainer(String text) {
25+
final container = ProviderContainer(
26+
overrides: [
27+
sessionsProvider.overrideWithValue(SessionsState([_session()])),
28+
eventsProvider.overrideWithValue(EventsState(const {}, const {})),
29+
chatItemsProvider(
30+
's1',
31+
).overrideWithValue([ThinkingItem(seq: 1, ts: 0, text: text)]),
32+
],
33+
);
34+
container.read(selectedSessionProvider.notifier).state = 's1';
35+
return container;
36+
}
37+
1238
void main() {
39+
group('_ThinkingLine interaction', () {
40+
const thinking = 'Reasoning about the answer in detail';
41+
42+
Future<void> pumpThinking(WidgetTester tester) async {
43+
final container = _thinkingContainer(thinking);
44+
addTearDown(container.dispose);
45+
await tester.pumpWidget(
46+
UncontrolledProviderScope(
47+
container: container,
48+
child: const MaterialApp(home: Scaffold(body: DesktopChatPane())),
49+
),
50+
);
51+
await tester.pump();
52+
}
53+
54+
testWidgets('collapsed by default: plain Text, no SelectableText', (
55+
tester,
56+
) async {
57+
await pumpThinking(tester);
58+
59+
expect(find.byType(SelectableText), findsNothing);
60+
final textWidget = tester.widget<Text>(find.text(thinking));
61+
expect(textWidget.maxLines, 1);
62+
expect(textWidget.overflow, TextOverflow.ellipsis);
63+
});
64+
65+
testWidgets('tapping the collapsed row expands to a SelectableText', (
66+
tester,
67+
) async {
68+
await pumpThinking(tester);
69+
70+
await tester.tap(find.text(thinking));
71+
await tester.pump();
72+
73+
expect(find.byType(SelectableText), findsOneWidget);
74+
});
75+
76+
testWidgets('expanded exposes a "Collapse thinking" semantics action', (
77+
tester,
78+
) async {
79+
await pumpThinking(tester);
80+
await tester.tap(find.text(thinking));
81+
await tester.pump();
82+
83+
final semantics = tester.widgetList<Semantics>(find.byType(Semantics));
84+
expect(
85+
semantics.any(
86+
(s) => s.properties.hintOverrides?.onTapHint == 'Collapse thinking',
87+
),
88+
isTrue,
89+
);
90+
});
91+
92+
testWidgets('tapping the leading icon while expanded collapses', (
93+
tester,
94+
) async {
95+
await pumpThinking(tester);
96+
await tester.tap(find.text(thinking));
97+
await tester.pump();
98+
expect(find.byType(SelectableText), findsOneWidget);
99+
100+
await tester.tap(find.byIcon(Symbols.psychology));
101+
await tester.pump();
102+
103+
expect(find.byType(SelectableText), findsNothing);
104+
});
105+
});
106+
13107
testWidgets('shows empty state when no session is selected', (tester) async {
14108
await tester.pumpWidget(
15109
const ProviderScope(

0 commit comments

Comments
 (0)