Skip to content

Commit 7260c2c

Browse files
authored
Make thinking tokens selectable when expanded (#45)
* Make thinking tokens selectable when expanded Expanded thinking cards now use SelectableText so text can be dragged to select/copy. A plain tap (no drag) still collapses the card via SelectableText's onTap, while the collapsed state keeps its tap-to-expand InkWell. * Restore thinking card accessibility
1 parent ccbf4c4 commit 7260c2c

2 files changed

Lines changed: 132 additions & 23 deletions

File tree

app/lib/ui/session/session_screen.dart

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -490,31 +490,42 @@ class _ThinkingCardState extends State<_ThinkingCard> {
490490
fontStyle: FontStyle.italic,
491491
height: 1.3,
492492
);
493-
return InkWell(
494-
onTap: () => setState(() => _expanded = !_expanded),
495-
child: Padding(
496-
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
497-
child: Row(
498-
crossAxisAlignment: CrossAxisAlignment.start,
499-
children: [
500-
Icon(
501-
Icons.psychology_outlined,
502-
size: 15,
503-
color: cs.onSurfaceVariant.withValues(alpha: 0.55),
504-
),
505-
const SizedBox(width: 6),
506-
Expanded(
507-
child: Text(
508-
widget.text.trim(),
509-
style: style,
510-
maxLines: _expanded ? null : 1,
511-
overflow: _expanded ? TextOverflow.clip : TextOverflow.ellipsis,
512-
),
513-
),
514-
],
515-
),
493+
void toggle() => setState(() => _expanded = !_expanded);
494+
final row = Padding(
495+
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
496+
child: Row(
497+
crossAxisAlignment: CrossAxisAlignment.start,
498+
children: [
499+
Icon(
500+
Icons.psychology_outlined,
501+
size: 15,
502+
color: cs.onSurfaceVariant.withValues(alpha: 0.55),
503+
),
504+
const SizedBox(width: 6),
505+
Expanded(
506+
child: _expanded
507+
// When expanded the text is selectable. A plain tap (no drag)
508+
// still collapses the card; dragging selects text instead.
509+
? SelectableText(
510+
widget.text.trim(),
511+
style: style,
512+
onTap: toggle,
513+
)
514+
: Text(
515+
widget.text.trim(),
516+
style: style,
517+
maxLines: 1,
518+
overflow: TextOverflow.ellipsis,
519+
),
520+
),
521+
],
516522
),
517523
);
524+
// Collapsed: whole row is a tap target that expands. Expanded: rely on
525+
// SelectableText's onTap so selecting text doesn't collapse the card.
526+
return _expanded
527+
? Semantics(onTap: toggle, onTapHint: 'Collapse thinking', child: row)
528+
: InkWell(onTap: toggle, child: row);
518529
}
519530
}
520531

app/test/session_screen_test.dart

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import 'dart:ui' show SemanticsAction;
2+
3+
import 'package:flutter/material.dart';
4+
import 'package:flutter_riverpod/flutter_riverpod.dart';
5+
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
6+
import 'package:flutter_test/flutter_test.dart';
7+
import 'package:makit/store/connection.dart';
8+
import 'package:makit/store/models.dart';
9+
import 'package:makit/store/store.dart';
10+
import 'package:makit/ui/session/session_screen.dart';
11+
12+
class _EmptyStorage extends FlutterSecureStorage {
13+
const _EmptyStorage() : super();
14+
15+
@override
16+
Future<String?> read({
17+
required String key,
18+
AppleOptions? iOptions,
19+
AndroidOptions? aOptions,
20+
LinuxOptions? lOptions,
21+
WebOptions? webOptions,
22+
AppleOptions? mOptions,
23+
WindowsOptions? wOptions,
24+
}) async => null;
25+
26+
@override
27+
Future<void> write({
28+
required String key,
29+
required String? value,
30+
AppleOptions? iOptions,
31+
AndroidOptions? aOptions,
32+
LinuxOptions? lOptions,
33+
WebOptions? webOptions,
34+
AppleOptions? mOptions,
35+
WindowsOptions? wOptions,
36+
}) async {}
37+
38+
@override
39+
Future<void> delete({
40+
required String key,
41+
AppleOptions? iOptions,
42+
AndroidOptions? aOptions,
43+
LinuxOptions? lOptions,
44+
WebOptions? webOptions,
45+
AppleOptions? mOptions,
46+
WindowsOptions? wOptions,
47+
}) async {}
48+
}
49+
50+
void main() {
51+
testWidgets('expanded thinking can be collapsed through semantics', (
52+
tester,
53+
) async {
54+
const sessionId = 's1';
55+
const thinking = 'A detailed reasoning trace';
56+
final session = Session(
57+
id: sessionId,
58+
projectId: 'p1',
59+
agent: 'pi',
60+
title: 'Session',
61+
status: SessionStatus.idle,
62+
policy: ApprovalPolicy.askOnRisky,
63+
);
64+
65+
await tester.pumpWidget(
66+
ProviderScope(
67+
overrides: [
68+
connectionControllerProvider.overrideWith(
69+
(ref) => ConnectionController(const _EmptyStorage()),
70+
),
71+
projectsProvider.overrideWithValue(ProjectsState(const [])),
72+
sessionsProvider.overrideWithValue(SessionsState([session])),
73+
chatItemsProvider(
74+
sessionId,
75+
).overrideWithValue([ThinkingItem(seq: 1, ts: 0, text: thinking)]),
76+
sessionMetaProvider(sessionId).overrideWithValue(null),
77+
sessionActionErrorProvider(sessionId).overrideWithValue(null),
78+
commandsProvider(sessionId).overrideWithValue(const []),
79+
],
80+
child: const MaterialApp(home: SessionScreen(sessionId: sessionId)),
81+
),
82+
);
83+
await tester.pump();
84+
85+
expect(find.byType(SelectableText), findsNothing);
86+
await tester.tap(find.text(thinking));
87+
await tester.pump();
88+
89+
final semantics = tester.getSemantics(find.byType(SelectableText));
90+
expect(semantics.getSemanticsData().hasAction(SemanticsAction.tap), isTrue);
91+
92+
semantics.owner!.performAction(semantics.id, SemanticsAction.tap);
93+
await tester.pump();
94+
95+
expect(find.byType(SelectableText), findsNothing);
96+
await tester.pump(const Duration(milliseconds: 600));
97+
});
98+
}

0 commit comments

Comments
 (0)