Skip to content

Commit 4ddd14e

Browse files
authored
[AI assistance] Basic UI for chat messages (#9630)
1 parent 559cf8d commit 4ddd14e

3 files changed

Lines changed: 240 additions & 8 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright 2026 The Flutter Authors
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
4+
5+
import 'package:devtools_app_shared/utils.dart';
6+
7+
import 'ai_message_types.dart';
8+
9+
class AiController extends DisposableController
10+
with AutoDisposeControllerMixin {
11+
AiController();
12+
13+
Future<ChatMessage> sendMessage(ChatMessage _) async {
14+
await Future.delayed(const Duration(seconds: 3));
15+
return const ChatMessage(text: _loremIpsum, isUser: false);
16+
}
17+
}
18+
19+
const _loremIpsum = '''
20+
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
21+
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis
22+
nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
23+
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu
24+
fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in
25+
culpa qui officia deserunt mollit anim id est laborum.
26+
''';
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Copyright 2026 The Flutter Authors
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
4+
5+
class ChatMessage {
6+
const ChatMessage({required this.text, required this.isUser});
7+
final String text;
8+
final bool isUser;
9+
}

packages/devtools_app/lib/src/shared/ai_assistant/widgets/ai_assistant_pane.dart

Lines changed: 205 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,225 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
44

5+
import 'dart:math' as math;
6+
7+
import 'package:devtools_app_shared/ui.dart';
58
import 'package:flutter/material.dart';
9+
import 'package:flutter/services.dart';
610

711
import '../../../framework/scaffold/bottom_pane.dart';
812
import '../../ui/tab.dart';
13+
import '../../utils/utils.dart';
14+
import '../ai_controller.dart';
15+
import '../ai_message_types.dart';
916

10-
class AiAssistantPane extends StatelessWidget implements TabbedPane {
17+
class AiAssistantPane extends StatefulWidget implements TabbedPane {
1118
const AiAssistantPane({super.key});
1219

1320
@override
14-
DevToolsTab get tab =>
15-
DevToolsTab.create(tabName: _tabName, gaPrefix: _gaPrefix);
21+
DevToolsTab get tab => DevToolsTab.create(
22+
tabName: AiAssistantPane._tabName,
23+
gaPrefix: AiAssistantPane._gaPrefix,
24+
);
1625

1726
static const _tabName = 'AI Assistant';
18-
1927
static const _gaPrefix = 'aiAssistant';
2028

29+
@override
30+
State<AiAssistantPane> createState() => _AiAssistantPaneState();
31+
}
32+
33+
class _AiAssistantPaneState extends State<AiAssistantPane> {
34+
static const _baseOverscrollPadding = 125.0;
35+
static const _spinnerHeight = 50.0;
36+
static const _scrollDuration = Duration(milliseconds: 250);
37+
38+
final _textController = TextEditingController();
39+
final _messages = <ChatMessage>[];
40+
final _scrollController = ScrollController();
41+
final _aiController = AiController();
42+
late final FocusNode _focusNode;
43+
44+
bool _isThinking = false;
45+
double _overscrollPadding = _baseOverscrollPadding;
46+
47+
@override
48+
void initState() {
49+
super.initState();
50+
_focusNode = FocusNode(onKeyEvent: _handleEnterKey);
51+
}
52+
53+
@override
54+
void dispose() {
55+
_focusNode.dispose();
56+
_textController.dispose();
57+
super.dispose();
58+
}
59+
60+
KeyEventResult _handleEnterKey(FocusNode node, KeyEvent event) {
61+
final isEnterKey =
62+
event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.enter;
63+
64+
if (isEnterKey && !HardwareKeyboard.instance.isShiftPressed) {
65+
if (!_isThinking) {
66+
safeUnawaited(_sendMessage());
67+
}
68+
return KeyEventResult.handled;
69+
}
70+
71+
return KeyEventResult.ignored;
72+
}
73+
74+
Future<void> _sendMessage() async {
75+
final messageText = _textController.text;
76+
if (messageText.isEmpty) return;
77+
_textController.clear();
78+
79+
final userMessage = ChatMessage(text: messageText, isUser: true);
80+
setState(() {
81+
_overscrollPadding = _calculateOverscrollPadding(userMessage);
82+
_isThinking = true;
83+
_messages.add(userMessage);
84+
});
85+
_scrollToBottom();
86+
87+
final aiResponse = await _aiController.sendMessage(userMessage);
88+
setState(() {
89+
_isThinking = false;
90+
_overscrollPadding = _calculateOverscrollPadding(aiResponse);
91+
_messages.add(aiResponse);
92+
});
93+
_scrollToBottom();
94+
}
95+
96+
void _scrollToBottom() {
97+
WidgetsBinding.instance.addPostFrameCallback((_) {
98+
if (_scrollController.hasClients) {
99+
safeUnawaited(
100+
_scrollController.animateTo(
101+
_scrollController.position.maxScrollExtent,
102+
duration: _scrollDuration,
103+
curve: Curves.ease,
104+
),
105+
);
106+
}
107+
});
108+
}
109+
110+
double _calculateOverscrollPadding(ChatMessage message) {
111+
final messageHeight =
112+
message.text.split('\n').length * (defaultFontSize + densePadding);
113+
final overscrollPadding = _baseOverscrollPadding + messageHeight;
114+
return message.isUser
115+
? overscrollPadding + _spinnerHeight
116+
: overscrollPadding;
117+
}
118+
119+
@override
120+
Widget build(BuildContext context) {
121+
return LayoutBuilder(
122+
builder: (context, constraints) {
123+
return Column(
124+
children: [
125+
Expanded(
126+
child: ListView.builder(
127+
padding: EdgeInsets.only(
128+
bottom: math.max(
129+
0,
130+
constraints.maxHeight - _overscrollPadding,
131+
),
132+
),
133+
controller: _scrollController,
134+
itemCount: _isThinking
135+
? _messages.length + 1
136+
: _messages.length,
137+
itemBuilder: (context, index) {
138+
if (_isThinking && index == _messages.length) {
139+
return const _ThinkingSpinner();
140+
}
141+
return _ChatMessageBubble(message: _messages[index]);
142+
},
143+
),
144+
),
145+
ConstrainedBox(
146+
constraints: BoxConstraints(maxHeight: constraints.maxHeight),
147+
child: Padding(
148+
padding: const EdgeInsets.all(denseSpacing),
149+
child: RoundedOutlinedBorder(
150+
child: Padding(
151+
// ignore: prefer-correct-edge-insets-constructor, false positive.
152+
padding: const EdgeInsets.fromLTRB(
153+
defaultSpacing,
154+
noPadding,
155+
defaultSpacing,
156+
densePadding,
157+
),
158+
child: TextField(
159+
controller: _textController,
160+
focusNode: _focusNode,
161+
keyboardType: TextInputType.multiline,
162+
textAlignVertical: TextAlignVertical.center,
163+
minLines: 1,
164+
maxLines: 10,
165+
decoration: InputDecoration(
166+
hintText: 'Ask a question...',
167+
border: InputBorder.none,
168+
suffixIcon: IconButton(
169+
icon: const Icon(Icons.send),
170+
onPressed: _isThinking ? null : _sendMessage,
171+
),
172+
),
173+
),
174+
),
175+
),
176+
),
177+
),
178+
],
179+
);
180+
},
181+
);
182+
}
183+
}
184+
185+
class _ChatMessageBubble extends StatelessWidget {
186+
const _ChatMessageBubble({required this.message});
187+
188+
final ChatMessage message;
189+
190+
@override
191+
Widget build(BuildContext context) {
192+
final colorScheme = Theme.of(context).colorScheme;
193+
return Align(
194+
alignment: message.isUser ? Alignment.centerRight : Alignment.centerLeft,
195+
child: Container(
196+
decoration: BoxDecoration(
197+
color: message.isUser
198+
? colorScheme.primaryContainer
199+
: colorScheme.secondaryContainer,
200+
borderRadius: defaultBorderRadius,
201+
),
202+
padding: const EdgeInsets.all(defaultSpacing),
203+
margin: const EdgeInsets.all(denseSpacing),
204+
child: Text(message.text),
205+
),
206+
);
207+
}
208+
}
209+
210+
class _ThinkingSpinner extends StatelessWidget {
211+
const _ThinkingSpinner();
212+
21213
@override
22214
Widget build(BuildContext context) {
23-
return const Column(
24-
children: [
25-
Expanded(child: Center(child: Text('TODO: Implement AI Assistant.'))),
26-
],
215+
return const Align(
216+
alignment: Alignment.centerLeft,
217+
child: Padding(
218+
padding: EdgeInsets.symmetric(
219+
vertical: denseSpacing,
220+
horizontal: extraLargeSpacing,
221+
),
222+
child: CircularProgressIndicator(),
223+
),
27224
);
28225
}
29226
}

0 commit comments

Comments
 (0)