diff --git a/it0_app/lib/features/chat/presentation/pages/chat_page.dart b/it0_app/lib/features/chat/presentation/pages/chat_page.dart index 3af272a..d4db696 100644 --- a/it0_app/lib/features/chat/presentation/pages/chat_page.dart +++ b/it0_app/lib/features/chat/presentation/pages/chat_page.dart @@ -53,11 +53,23 @@ class _ChatPageState extends ConsumerState { _scrollToBottom(); } - void _scrollToBottom() { + void _scrollToBottom({bool jump = false}) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { + if (!_scrollController.hasClients) return; + final target = _scrollController.position.maxScrollExtent + 80; + if (jump) { + _scrollController.jumpTo(target); + // Second frame: layout may still be settling for large message lists + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.jumpTo( + _scrollController.position.maxScrollExtent + 80, + ); + } + }); + } else { _scrollController.animateTo( - _scrollController.position.maxScrollExtent + 80, + target, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); @@ -454,7 +466,12 @@ class _ChatPageState extends ConsumerState { final chatState = ref.watch(chatProvider); // Auto-scroll when messages change - ref.listen(chatProvider, (_, __) => _scrollToBottom()); + ref.listen(chatProvider, (prev, next) { + // Jump (no animation) when loading a conversation history + final wasEmpty = prev?.messages.isEmpty ?? true; + final nowHasMany = next.messages.length > 1; + _scrollToBottom(jump: wasEmpty && nowHasMany); + }); return Scaffold( drawer: const ConversationDrawer(), @@ -510,45 +527,51 @@ class _ChatPageState extends ConsumerState { ), ), - // Timeline message list + // Timeline message list + floating input Expanded( - child: chatState.messages.isEmpty - ? _buildEmptyState() - : ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), - // Extra item for the "working" placeholder when streaming - // and the last message is still a user message (no agent - // response has arrived yet). - itemCount: chatState.messages.length + - (_needsWorkingNode(chatState) ? 1 : 0), - itemBuilder: (context, index) { - // Render the virtual "working" node at the end - if (index == chatState.messages.length && - _needsWorkingNode(chatState)) { - return TimelineEventNode( - status: NodeStatus.active, - label: '处理中...', - isFirst: false, - isLast: false, - ); - } - final isRealLast = - index == chatState.messages.length - 1; - return _buildTimelineNode( - chatState.messages[index], - chatState, - isFirst: index == 0, - isLast: isRealLast && - !chatState.isStreaming && - !_needsWorkingNode(chatState), - ); - }, - ), + child: Stack( + children: [ + // Messages fill the entire area + chatState.messages.isEmpty + ? _buildEmptyState() + : ListView.builder( + controller: _scrollController, + // Bottom padding leaves room for the floating input pill + padding: const EdgeInsets.fromLTRB(12, 8, 12, 80), + itemCount: chatState.messages.length + + (_needsWorkingNode(chatState) ? 1 : 0), + itemBuilder: (context, index) { + if (index == chatState.messages.length && + _needsWorkingNode(chatState)) { + return TimelineEventNode( + status: NodeStatus.active, + label: '处理中...', + isFirst: false, + isLast: false, + ); + } + final isRealLast = + index == chatState.messages.length - 1; + return _buildTimelineNode( + chatState.messages[index], + chatState, + isFirst: index == 0, + isLast: isRealLast && + !chatState.isStreaming && + !_needsWorkingNode(chatState), + ); + }, + ), + // Floating input pill at bottom + Positioned( + left: 12, + right: 12, + bottom: 8, + child: _buildInputArea(chatState), + ), + ], + ), ), - - // Input row - _buildInputArea(chatState), ], ), ), @@ -592,22 +615,36 @@ class _ChatPageState extends ConsumerState { final isStreaming = chatState.isStreaming && !isAwaitingApproval; return Container( - padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: AppColors.surface, - border: Border(top: BorderSide(color: AppColors.surfaceLight.withOpacity(0.5))), + color: AppColors.surface.withOpacity(0.92), + borderRadius: BorderRadius.circular(28), + border: Border.all(color: AppColors.surfaceLight.withOpacity(0.6)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.25), + blurRadius: 12, + offset: const Offset(0, 2), + ), + ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (_pendingAttachments.isNotEmpty) _buildAttachmentPreview(), + if (_pendingAttachments.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), + child: _buildAttachmentPreview(), + ), Row( children: [ if (!isStreaming) - IconButton( - icon: const Icon(Icons.add_circle_outline), - tooltip: '添加图片', - onPressed: isAwaitingApproval ? null : _showAttachmentOptions, + Padding( + padding: const EdgeInsets.only(left: 4), + child: IconButton( + icon: const Icon(Icons.add_circle_outline, size: 22), + tooltip: '添加图片', + onPressed: isAwaitingApproval ? null : _showAttachmentOptions, + ), ), Expanded( child: TextField( @@ -615,37 +652,45 @@ class _ChatPageState extends ConsumerState { decoration: InputDecoration( hintText: isStreaming ? '追加指令...' : '输入指令...', hintStyle: TextStyle(color: AppColors.textMuted), - border: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(24)), + border: InputBorder.none, + contentPadding: EdgeInsets.only( + left: isStreaming ? 16 : 4, + right: 4, + top: 12, + bottom: 12, ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), ), textInputAction: TextInputAction.send, onSubmitted: (_) => isStreaming ? _inject() : _send(), enabled: !isAwaitingApproval, ), ), - const SizedBox(width: 8), if (isStreaming) Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( - icon: const Icon(Icons.send, color: AppColors.info), + icon: const Icon(Icons.send, color: AppColors.info, size: 20), tooltip: '追加指令', onPressed: _inject, ), - IconButton( - icon: const Icon(Icons.stop_circle_outlined, color: AppColors.error), - tooltip: '停止', - onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(), + Padding( + padding: const EdgeInsets.only(right: 4), + child: IconButton( + icon: const Icon(Icons.stop_circle_outlined, color: AppColors.error, size: 20), + tooltip: '停止', + onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(), + ), ), ], ) else - IconButton( - icon: const Icon(Icons.send), - onPressed: isAwaitingApproval ? null : _send, + Padding( + padding: const EdgeInsets.only(right: 4), + child: IconButton( + icon: const Icon(Icons.send, size: 20), + onPressed: isAwaitingApproval ? null : _send, + ), ), ], ),