diff --git a/lib/features/assistant/assistant_page.dart b/lib/features/assistant/assistant_page.dart index 72538d95..8c366a6c 100644 --- a/lib/features/assistant/assistant_page.dart +++ b/lib/features/assistant/assistant_page.dart @@ -829,8 +829,7 @@ class _AssistantPageState extends State { Future _createNewThread() async { final sessionKey = _buildDraftSessionKey(widget.controller); final inheritedTarget = widget.controller.currentAssistantExecutionTarget; - final inheritedViewMode = - widget.controller.currentAssistantMessageViewMode; + final inheritedViewMode = widget.controller.currentAssistantMessageViewMode; setState(() { _archivedTaskKeys.removeWhere( (value) => _sessionKeysMatch(value, sessionKey), @@ -1592,7 +1591,7 @@ class _ConversationArea extends StatelessWidget { final VoidCallback onOpenAiGatewaySettings; final Future Function() onReconnectGateway; final Future Function(AssistantMessageViewMode mode) - onMessageViewModeChanged; + onMessageViewModeChanged; @override Widget build(BuildContext context) { @@ -2312,7 +2311,7 @@ class _AssistantEmptyState extends StatelessWidget { } } -class _ComposerBar extends StatelessWidget { +class _ComposerBar extends StatefulWidget { const _ComposerBar({ required this.controller, required this.inputController, @@ -2353,9 +2352,40 @@ class _ComposerBar extends StatelessWidget { final VoidCallback onPickAttachments; final Future Function() onSend; + @override + State<_ComposerBar> createState() => _ComposerBarState(); +} + +class _ComposerBarState extends State<_ComposerBar> { + static const double _minInputHeight = 68; + static const double _defaultInputHeight = 78; + static const double _maxInputHeight = 220; + + late double _inputHeight; + + @override + void initState() { + super.initState(); + _inputHeight = _defaultInputHeight; + } + + void _resizeInput(double delta) { + final nextHeight = (_inputHeight + delta).clamp( + _minInputHeight, + _maxInputHeight, + ); + if (nextHeight == _inputHeight) { + return; + } + setState(() { + _inputHeight = nextHeight; + }); + } + @override Widget build(BuildContext context) { final palette = context.palette; + final controller = widget.controller; final aiGatewayOnly = controller.isAiGatewayOnlyMode; final connected = aiGatewayOnly ? controller.canUseAiGatewayConversation @@ -2366,8 +2396,8 @@ class _ComposerBar extends StatelessWidget { controller.connection.status == RuntimeConnectionStatus.connecting; final executionTarget = controller.assistantExecutionTarget; final permissionLevel = controller.assistantPermissionLevel; - final selectedSkills = availableSkills - .where((skill) => selectedSkillKeys.contains(skill.key)) + final selectedSkills = widget.availableSkills + .where((skill) => widget.selectedSkillKeys.contains(skill.key)) .toList(growable: false); final submitLabel = connected ? appText('提交', 'Submit') @@ -2394,7 +2424,7 @@ class _ComposerBar extends StatelessWidget { onSelected: (value) { switch (value) { case 'attach': - onPickAttachments(); + widget.onPickAttachments(); break; } }, @@ -2504,49 +2534,59 @@ class _ComposerBar extends StatelessWidget { ], ), const SizedBox(height: 8), - if (attachments.isNotEmpty) ...[ + if (widget.attachments.isNotEmpty) ...[ Wrap( spacing: 6, runSpacing: 6, - children: attachments + children: widget.attachments .map( (attachment) => InputChip( avatar: Icon(attachment.icon, size: 16), label: Text(attachment.name), - onDeleted: () => onRemoveAttachment(attachment), + onDeleted: () => widget.onRemoveAttachment(attachment), ), ) .toList(), ), const SizedBox(height: 6), ], - TextField( - controller: inputController, - focusNode: focusNode, - autofocus: true, - minLines: 2, - maxLines: 4, - decoration: InputDecoration( - isCollapsed: true, - filled: true, - fillColor: palette.chromeSurface, - contentPadding: const EdgeInsets.fromLTRB(10, 8, 10, 8), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: palette.chromeStroke), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: palette.accent.withValues(alpha: 0.18), + SizedBox( + key: const Key('assistant-composer-input-area'), + height: _inputHeight, + child: TextField( + controller: widget.inputController, + focusNode: widget.focusNode, + autofocus: true, + expands: true, + minLines: null, + maxLines: null, + textAlignVertical: TextAlignVertical.top, + decoration: InputDecoration( + isCollapsed: true, + filled: true, + fillColor: palette.chromeSurface, + contentPadding: const EdgeInsets.fromLTRB(10, 8, 10, 8), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: palette.chromeStroke), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: palette.accent.withValues(alpha: 0.18), + ), + ), + hintText: appText( + '输入需求、补充上下文、继续追问,XWorkmate 会沿用当前任务上下文持续处理。', + 'Describe the task, add context, or continue the thread. XWorkmate keeps the current task context.', ), ), - hintText: appText( - '输入需求、补充上下文、继续追问,XWorkmate 会沿用当前任务上下文持续处理。', - 'Describe the task, add context, or continue the thread. XWorkmate keeps the current task context.', - ), + onSubmitted: (_) => widget.onSend(), ), - onSubmitted: (_) => onSend(), + ), + _ComposerResizeHandle( + key: const Key('assistant-composer-resize-handle'), + onDelta: _resizeInput, ), if (selectedSkills.isNotEmpty) ...[ const SizedBox(height: 6), @@ -2560,7 +2600,7 @@ class _ComposerBar extends StatelessWidget { 'assistant-selected-skill-${skill.key}', ), option: skill, - onDeleted: () => onToggleSkill(skill.key), + onDeleted: () => widget.onToggleSkill(skill.key), ), ) .toList(growable: false), @@ -2627,26 +2667,26 @@ class _ComposerBar extends StatelessWidget { ), ), const SizedBox(width: 6), - modelOptions.isEmpty + widget.modelOptions.isEmpty ? _ComposerToolbarChip( key: const Key('assistant-model-button'), icon: Icons.bolt_rounded, - label: modelLabel, + label: widget.modelLabel, showChevron: false, maxLabelWidth: 140, ) : PopupMenuButton( key: const Key('assistant-model-button'), tooltip: appText('模型', 'Model'), - onSelected: onModelChanged, - itemBuilder: (context) => modelOptions + onSelected: widget.onModelChanged, + itemBuilder: (context) => widget.modelOptions .map( (value) => PopupMenuItem( value: value, child: Row( children: [ Expanded(child: Text(value)), - if (value == modelLabel) + if (value == widget.modelLabel) const Icon( Icons.check_rounded, size: 18, @@ -2658,7 +2698,7 @@ class _ComposerBar extends StatelessWidget { .toList(), child: _ComposerToolbarChip( icon: Icons.bolt_rounded, - label: modelLabel, + label: widget.modelLabel, showChevron: true, maxLabelWidth: 140, ), @@ -2667,7 +2707,7 @@ class _ComposerBar extends StatelessWidget { PopupMenuButton( key: const Key('assistant-thinking-button'), tooltip: appText('推理强度', 'Reasoning'), - onSelected: onThinkingChanged, + onSelected: widget.onThinkingChanged, itemBuilder: (context) => const ['low', 'medium', 'high', 'max'] .map( @@ -2680,7 +2720,7 @@ class _ComposerBar extends StatelessWidget { _assistantThinkingLabel(value), ), ), - if (value == thinkingLabel) + if (value == widget.thinkingLabel) const Icon( Icons.check_rounded, size: 18, @@ -2692,7 +2732,7 @@ class _ComposerBar extends StatelessWidget { .toList(), child: _ComposerToolbarChip( icon: Icons.psychology_alt_outlined, - label: _assistantThinkingLabel(thinkingLabel), + label: _assistantThinkingLabel(widget.thinkingLabel), showChevron: true, maxLabelWidth: 96, ), @@ -2708,14 +2748,14 @@ class _ComposerBar extends StatelessWidget { onPressed: connecting ? null : connected - ? onSend + ? widget.onSend : aiGatewayOnly - ? onOpenAiGatewaySettings + ? widget.onOpenAiGatewaySettings : reconnectAvailable ? () async { - await onReconnectGateway(); + await widget.onReconnectGateway(); } - : onOpenGateway, + : widget.onOpenGateway, style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 10, @@ -2760,7 +2800,7 @@ class _ComposerBar extends StatelessWidget { builder: (dialogContext) { return StatefulBuilder( builder: (context, setDialogState) { - final filteredSkills = availableSkills + final filteredSkills = widget.availableSkills .where((skill) { if (query.trim().isEmpty) { return true; @@ -2819,9 +2859,8 @@ class _ComposerBar extends StatelessWidget { const SizedBox(height: 8), itemBuilder: (context, index) { final skill = filteredSkills[index]; - final selected = selectedSkillKeys.contains( - skill.key, - ); + final selected = widget.selectedSkillKeys + .contains(skill.key); return _SkillPickerTile( key: ValueKey( 'assistant-skill-option-${skill.key}', @@ -2829,7 +2868,7 @@ class _ComposerBar extends StatelessWidget { option: skill, selected: selected, onTap: () { - onToggleSkill(skill.key); + widget.onToggleSkill(skill.key); Navigator.of(dialogContext).pop(); }, ); @@ -2858,6 +2897,56 @@ class _ComposerIconButton extends StatefulWidget { State<_ComposerIconButton> createState() => _ComposerIconButtonState(); } +class _ComposerResizeHandle extends StatefulWidget { + const _ComposerResizeHandle({super.key, required this.onDelta}); + + final ValueChanged onDelta; + + @override + State<_ComposerResizeHandle> createState() => _ComposerResizeHandleState(); +} + +class _ComposerResizeHandleState extends State<_ComposerResizeHandle> { + bool _hovered = false; + bool _dragging = false; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + final highlight = _hovered || _dragging; + + return MouseRegion( + cursor: SystemMouseCursors.resizeRow, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragStart: (_) => setState(() => _dragging = true), + onVerticalDragEnd: (_) => setState(() => _dragging = false), + onVerticalDragCancel: () => setState(() => _dragging = false), + onVerticalDragUpdate: (details) => widget.onDelta(details.delta.dy), + child: SizedBox( + height: 12, + width: double.infinity, + child: Center( + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + width: 42, + height: 2, + decoration: BoxDecoration( + color: highlight + ? palette.accent.withValues(alpha: 0.72) + : palette.strokeSoft, + borderRadius: BorderRadius.circular(999), + ), + ), + ), + ), + ), + ); + } +} + class _ComposerIconButtonState extends State<_ComposerIconButton> { bool _hovered = false; @@ -3051,10 +3140,7 @@ class _MessageBubble extends StatelessWidget { } class _MessageBubbleBody extends StatelessWidget { - const _MessageBubbleBody({ - required this.text, - required this.renderMarkdown, - }); + const _MessageBubbleBody({required this.text, required this.renderMarkdown}); final String text; final bool renderMarkdown; @@ -3524,10 +3610,7 @@ class _ConnectionChip extends StatelessWidget { } class _MessageViewModeChip extends StatelessWidget { - const _MessageViewModeChip({ - required this.value, - required this.onSelected, - }); + const _MessageViewModeChip({required this.value, required this.onSelected}); final AssistantMessageViewMode value; final Future Function(AssistantMessageViewMode mode) onSelected; diff --git a/test/features/assistant_page_test.dart b/test/features/assistant_page_test.dart index c6191135..1541fca2 100644 --- a/test/features/assistant_page_test.dart +++ b/test/features/assistant_page_test.dart @@ -405,6 +405,34 @@ void main() { expect(find.text('网页处理'), findsOneWidget); }); + testWidgets('AssistantPage composer input area can be resized vertically', ( + WidgetTester tester, + ) async { + final controller = await createTestController(tester); + + await pumpPage( + tester, + child: AssistantPage(controller: controller, onOpenDetail: (_) {}), + ); + + final inputArea = find.byKey(const Key('assistant-composer-input-area')); + final resizeHandle = find.byKey( + const Key('assistant-composer-resize-handle'), + ); + + expect(inputArea, findsOneWidget); + expect(resizeHandle, findsOneWidget); + + final initialHeight = tester.getSize(inputArea).height; + + await tester.drag(resizeHandle, const Offset(0, 40)); + await tester.pumpAndSettle(); + + final expandedHeight = tester.getSize(inputArea).height; + + expect(expandedHeight, greaterThan(initialHeight)); + }); + // Known flutter_tester host-exit hang in this widget scenario. testWidgets( 'AssistantPage syncs task selection with execution target menu and connection chip', @@ -533,12 +561,17 @@ void main() { expect(find.byType(MarkdownBody), findsOneWidget); - await tester.tap(find.byKey(const Key('assistant-message-view-mode-button'))); + await tester.tap( + find.byKey(const Key('assistant-message-view-mode-button')), + ); await _pumpForUiSync(tester); await tester.tap(find.text('RAW').last); await _pumpForUiSync(tester); - expect(controller.currentAssistantMessageViewMode, AssistantMessageViewMode.raw); + expect( + controller.currentAssistantMessageViewMode, + AssistantMessageViewMode.raw, + ); expect(find.byType(MarkdownBody), findsNothing); }, skip: true);