diff --git a/lib/features/assistant/assistant_page.dart b/lib/features/assistant/assistant_page.dart index 5a70e3ee..d75b6b5e 100644 --- a/lib/features/assistant/assistant_page.dart +++ b/lib/features/assistant/assistant_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:math' as math; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; @@ -22,6 +23,8 @@ import '../../widgets/desktop_workspace_scaffold.dart'; import '../../widgets/pane_resize_handle.dart'; import '../../widgets/surface_card.dart'; +const double _assistantComposerDefaultInputHeight = 78; + class AssistantPage extends StatefulWidget { const AssistantPage({ super.key, @@ -68,6 +71,7 @@ class _AssistantPageState extends State { String? _lastSubmittedSessionKey; String? _lastAutoAgentLabel; List _lastSubmittedAttachments = const []; + double _composerContentHeight = 0; @override void initState() { @@ -361,28 +365,36 @@ class _AssistantPageState extends State { }) { return LayoutBuilder( builder: (context, constraints) { - final composerHeight = constraints.maxHeight >= 900 ? 180.0 : 152.0; + final baseComposerHeight = constraints.maxHeight >= 900 ? 180.0 : 152.0; + final composerHeight = math.min( + math.max(0.0, constraints.maxHeight - 2), + math.max(baseComposerHeight, _composerContentHeight), + ); return Column( children: [ Expanded( - child: _ConversationArea( - controller: controller, - currentTask: currentTask, - items: timelineItems, - messageViewMode: controller.currentAssistantMessageViewMode, - scrollController: _conversationController, - onOpenDetail: widget.onOpenDetail, - onFocusComposer: _focusComposer, - onOpenGateway: _showConnectDialog, - onOpenAiGatewaySettings: _openAiGatewaySettings, - onReconnectGateway: _connectFromSavedSettingsOrShowDialog, - onMessageViewModeChanged: - controller.setAssistantMessageViewMode, + child: KeyedSubtree( + key: const Key('assistant-conversation-shell'), + child: _ConversationArea( + controller: controller, + currentTask: currentTask, + items: timelineItems, + messageViewMode: controller.currentAssistantMessageViewMode, + scrollController: _conversationController, + onOpenDetail: widget.onOpenDetail, + onFocusComposer: _focusComposer, + onOpenGateway: _showConnectDialog, + onOpenAiGatewaySettings: _openAiGatewaySettings, + onReconnectGateway: _connectFromSavedSettingsOrShowDialog, + onMessageViewModeChanged: + controller.setAssistantMessageViewMode, + ), ), ), const SizedBox(height: 2), SizedBox( + key: const Key('assistant-composer-shell'), height: composerHeight, child: _AssistantLowerPane( inputController: _inputController, @@ -438,6 +450,8 @@ class _AssistantPageState extends State { onOpenAiGatewaySettings: _openAiGatewaySettings, onReconnectGateway: _connectFromSavedSettingsOrShowDialog, onPickAttachments: _pickAttachments, + onComposerContentHeightChanged: + _handleComposerContentHeightChanged, onSend: _submitPrompt, ), ), @@ -447,6 +461,15 @@ class _AssistantPageState extends State { ); } + void _handleComposerContentHeightChanged(double value) { + if (!mounted || value == _composerContentHeight) { + return; + } + setState(() { + _composerContentHeight = value; + }); + } + List<_TimelineItem> _buildTimelineItems( AppController controller, List messages, @@ -1556,6 +1579,7 @@ class _AssistantLowerPane extends StatelessWidget { required this.onOpenAiGatewaySettings, required this.onReconnectGateway, required this.onPickAttachments, + required this.onComposerContentHeightChanged, required this.onSend, }); @@ -1579,6 +1603,7 @@ class _AssistantLowerPane extends StatelessWidget { final VoidCallback onOpenAiGatewaySettings; final Future Function() onReconnectGateway; final VoidCallback onPickAttachments; + final ValueChanged onComposerContentHeightChanged; final Future Function() onSend; @override @@ -1608,6 +1633,7 @@ class _AssistantLowerPane extends StatelessWidget { onOpenAiGatewaySettings: onOpenAiGatewaySettings, onReconnectGateway: onReconnectGateway, onPickAttachments: onPickAttachments, + onContentHeightChanged: onComposerContentHeightChanged, onSend: onSend, ), ), @@ -2381,6 +2407,7 @@ class _ComposerBar extends StatefulWidget { required this.onOpenAiGatewaySettings, required this.onReconnectGateway, required this.onPickAttachments, + required this.onContentHeightChanged, required this.onSend, }); @@ -2404,6 +2431,7 @@ class _ComposerBar extends StatefulWidget { final VoidCallback onOpenAiGatewaySettings; final Future Function() onReconnectGateway; final VoidCallback onPickAttachments; + final ValueChanged onContentHeightChanged; final Future Function() onSend; @override @@ -2412,10 +2440,12 @@ class _ComposerBar extends StatefulWidget { class _ComposerBarState extends State<_ComposerBar> { static const double _minInputHeight = 68; - static const double _defaultInputHeight = 78; + static const double _defaultInputHeight = + _assistantComposerDefaultInputHeight; static const double _maxInputHeight = 220; late double _inputHeight; + double? _reportedContentHeight; @override void initState() { @@ -2436,8 +2466,23 @@ class _ComposerBarState extends State<_ComposerBar> { }); } + void _reportContentHeight() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + final height = context.size?.height; + if (height == null || height == _reportedContentHeight) { + return; + } + _reportedContentHeight = height; + widget.onContentHeightChanged(height); + }); + } + @override Widget build(BuildContext context) { + _reportContentHeight(); final palette = context.palette; final controller = widget.controller; final uiFeatures = controller.featuresFor( diff --git a/test/features/assistant_page_suite.dart b/test/features/assistant_page_suite.dart index 19efee22..3c479e31 100644 --- a/test/features/assistant_page_suite.dart +++ b/test/features/assistant_page_suite.dart @@ -463,20 +463,82 @@ void main() { final resizeHandle = find.byKey( const Key('assistant-composer-resize-handle'), ); + final conversationShell = find.byKey( + const Key('assistant-conversation-shell'), + ); + final composerShell = find.byKey(const Key('assistant-composer-shell')); expect(inputArea, findsOneWidget); expect(resizeHandle, findsOneWidget); + expect(conversationShell, findsOneWidget); + expect(composerShell, findsOneWidget); final initialHeight = tester.getSize(inputArea).height; + final initialComposerHeight = tester.getRect(composerShell).height; + final initialConversationHeight = tester.getRect(conversationShell).height; await tester.drag(resizeHandle, const Offset(0, 40)); await tester.pumpAndSettle(); final expandedHeight = tester.getSize(inputArea).height; + final expandedComposerHeight = tester.getRect(composerShell).height; + final expandedConversationHeight = tester.getRect(conversationShell).height; expect(expandedHeight, greaterThan(initialHeight)); + expect(expandedComposerHeight, greaterThan(initialComposerHeight)); + expect(expandedConversationHeight, lessThan(initialConversationHeight)); }); + testWidgets( + 'AssistantPage grows the composer shell for selected skills in short windows', + (WidgetTester tester) async { + final controller = await createTestController(tester); + + await pumpPage( + tester, + size: const Size(1600, 620), + child: AssistantPage(controller: controller, onOpenDetail: (_) {}), + ); + + final conversationShell = find.byKey( + const Key('assistant-conversation-shell'), + ); + final composerShell = find.byKey(const Key('assistant-composer-shell')); + final skillPickerButton = find.byKey( + const Key('assistant-skill-picker-button'), + ); + + expect(conversationShell, findsOneWidget); + expect(composerShell, findsOneWidget); + + final initialComposerHeight = tester.getRect(composerShell).height; + final initialConversationBottom = tester + .getRect(conversationShell) + .bottom; + + await tester.tap(skillPickerButton); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const ValueKey('assistant-skill-option-xlsx')), + ); + await tester.pumpAndSettle(); + + final expandedComposerHeight = tester.getRect(composerShell).height; + final expandedConversationBottom = tester + .getRect(conversationShell) + .bottom; + + expect(expandedComposerHeight, greaterThan(initialComposerHeight)); + expect( + expandedConversationBottom, + lessThanOrEqualTo(tester.getRect(composerShell).top), + ); + expect(expandedConversationBottom, lessThan(initialConversationBottom)); + expect(tester.takeException(), isNull); + }, + ); + // Known flutter_tester host-exit hang in this widget scenario. testWidgets( 'AssistantPage syncs task selection with execution target menu and connection chip',