From 558a826a3ccfc0bf4bb5b10a1644009751ce7d5d Mon Sep 17 00:00:00 2001 From: Elliott Brooks <21270878+elliette@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:35:30 -0800 Subject: [PATCH] Basic UI for chat messages --- .../shared/ai_assistant/ai_controller.dart | 26 +++ .../shared/ai_assistant/ai_message_types.dart | 9 + .../widgets/ai_assistant_pane.dart | 213 +++++++++++++++++- 3 files changed, 240 insertions(+), 8 deletions(-) create mode 100644 packages/devtools_app/lib/src/shared/ai_assistant/ai_controller.dart create mode 100644 packages/devtools_app/lib/src/shared/ai_assistant/ai_message_types.dart diff --git a/packages/devtools_app/lib/src/shared/ai_assistant/ai_controller.dart b/packages/devtools_app/lib/src/shared/ai_assistant/ai_controller.dart new file mode 100644 index 00000000000..879e9b2b6e4 --- /dev/null +++ b/packages/devtools_app/lib/src/shared/ai_assistant/ai_controller.dart @@ -0,0 +1,26 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'package:devtools_app_shared/utils.dart'; + +import 'ai_message_types.dart'; + +class AiController extends DisposableController + with AutoDisposeControllerMixin { + AiController(); + + Future sendMessage(ChatMessage _) async { + await Future.delayed(const Duration(seconds: 3)); + return const ChatMessage(text: _loremIpsum, isUser: false); + } +} + +const _loremIpsum = ''' +Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor +incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis +nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. +Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu +fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in +culpa qui officia deserunt mollit anim id est laborum. +'''; diff --git a/packages/devtools_app/lib/src/shared/ai_assistant/ai_message_types.dart b/packages/devtools_app/lib/src/shared/ai_assistant/ai_message_types.dart new file mode 100644 index 00000000000..c71223651fd --- /dev/null +++ b/packages/devtools_app/lib/src/shared/ai_assistant/ai_message_types.dart @@ -0,0 +1,9 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +class ChatMessage { + const ChatMessage({required this.text, required this.isUser}); + final String text; + final bool isUser; +} diff --git a/packages/devtools_app/lib/src/shared/ai_assistant/widgets/ai_assistant_pane.dart b/packages/devtools_app/lib/src/shared/ai_assistant/widgets/ai_assistant_pane.dart index b3d2128f4f6..d8c8c5f6174 100644 --- a/packages/devtools_app/lib/src/shared/ai_assistant/widgets/ai_assistant_pane.dart +++ b/packages/devtools_app/lib/src/shared/ai_assistant/widgets/ai_assistant_pane.dart @@ -2,28 +2,225 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +import 'dart:math' as math; + +import 'package:devtools_app_shared/ui.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../../framework/scaffold/bottom_pane.dart'; import '../../ui/tab.dart'; +import '../../utils/utils.dart'; +import '../ai_controller.dart'; +import '../ai_message_types.dart'; -class AiAssistantPane extends StatelessWidget implements TabbedPane { +class AiAssistantPane extends StatefulWidget implements TabbedPane { const AiAssistantPane({super.key}); @override - DevToolsTab get tab => - DevToolsTab.create(tabName: _tabName, gaPrefix: _gaPrefix); + DevToolsTab get tab => DevToolsTab.create( + tabName: AiAssistantPane._tabName, + gaPrefix: AiAssistantPane._gaPrefix, + ); static const _tabName = 'AI Assistant'; - static const _gaPrefix = 'aiAssistant'; + @override + State createState() => _AiAssistantPaneState(); +} + +class _AiAssistantPaneState extends State { + static const _baseOverscrollPadding = 125.0; + static const _spinnerHeight = 50.0; + static const _scrollDuration = Duration(milliseconds: 250); + + final _textController = TextEditingController(); + final _messages = []; + final _scrollController = ScrollController(); + final _aiController = AiController(); + late final FocusNode _focusNode; + + bool _isThinking = false; + double _overscrollPadding = _baseOverscrollPadding; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(onKeyEvent: _handleEnterKey); + } + + @override + void dispose() { + _focusNode.dispose(); + _textController.dispose(); + super.dispose(); + } + + KeyEventResult _handleEnterKey(FocusNode node, KeyEvent event) { + final isEnterKey = + event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.enter; + + if (isEnterKey && !HardwareKeyboard.instance.isShiftPressed) { + if (!_isThinking) { + safeUnawaited(_sendMessage()); + } + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + Future _sendMessage() async { + final messageText = _textController.text; + if (messageText.isEmpty) return; + _textController.clear(); + + final userMessage = ChatMessage(text: messageText, isUser: true); + setState(() { + _overscrollPadding = _calculateOverscrollPadding(userMessage); + _isThinking = true; + _messages.add(userMessage); + }); + _scrollToBottom(); + + final aiResponse = await _aiController.sendMessage(userMessage); + setState(() { + _isThinking = false; + _overscrollPadding = _calculateOverscrollPadding(aiResponse); + _messages.add(aiResponse); + }); + _scrollToBottom(); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + safeUnawaited( + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: _scrollDuration, + curve: Curves.ease, + ), + ); + } + }); + } + + double _calculateOverscrollPadding(ChatMessage message) { + final messageHeight = + message.text.split('\n').length * (defaultFontSize + densePadding); + final overscrollPadding = _baseOverscrollPadding + messageHeight; + return message.isUser + ? overscrollPadding + _spinnerHeight + : overscrollPadding; + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + return Column( + children: [ + Expanded( + child: ListView.builder( + padding: EdgeInsets.only( + bottom: math.max( + 0, + constraints.maxHeight - _overscrollPadding, + ), + ), + controller: _scrollController, + itemCount: _isThinking + ? _messages.length + 1 + : _messages.length, + itemBuilder: (context, index) { + if (_isThinking && index == _messages.length) { + return const _ThinkingSpinner(); + } + return _ChatMessageBubble(message: _messages[index]); + }, + ), + ), + ConstrainedBox( + constraints: BoxConstraints(maxHeight: constraints.maxHeight), + child: Padding( + padding: const EdgeInsets.all(denseSpacing), + child: RoundedOutlinedBorder( + child: Padding( + // ignore: prefer-correct-edge-insets-constructor, false positive. + padding: const EdgeInsets.fromLTRB( + defaultSpacing, + noPadding, + defaultSpacing, + densePadding, + ), + child: TextField( + controller: _textController, + focusNode: _focusNode, + keyboardType: TextInputType.multiline, + textAlignVertical: TextAlignVertical.center, + minLines: 1, + maxLines: 10, + decoration: InputDecoration( + hintText: 'Ask a question...', + border: InputBorder.none, + suffixIcon: IconButton( + icon: const Icon(Icons.send), + onPressed: _isThinking ? null : _sendMessage, + ), + ), + ), + ), + ), + ), + ), + ], + ); + }, + ); + } +} + +class _ChatMessageBubble extends StatelessWidget { + const _ChatMessageBubble({required this.message}); + + final ChatMessage message; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Align( + alignment: message.isUser ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + decoration: BoxDecoration( + color: message.isUser + ? colorScheme.primaryContainer + : colorScheme.secondaryContainer, + borderRadius: defaultBorderRadius, + ), + padding: const EdgeInsets.all(defaultSpacing), + margin: const EdgeInsets.all(denseSpacing), + child: Text(message.text), + ), + ); + } +} + +class _ThinkingSpinner extends StatelessWidget { + const _ThinkingSpinner(); + @override Widget build(BuildContext context) { - return const Column( - children: [ - Expanded(child: Center(child: Text('TODO: Implement AI Assistant.'))), - ], + return const Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: EdgeInsets.symmetric( + vertical: denseSpacing, + horizontal: extraLargeSpacing, + ), + child: CircularProgressIndicator(), + ), ); } }