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 3c6ff7e..b4f82de 100644 --- a/it0_app/lib/features/chat/presentation/pages/chat_page.dart +++ b/it0_app/lib/features/chat/presentation/pages/chat_page.dart @@ -51,6 +51,15 @@ class _ChatPageState extends ConsumerState { ); } + /// Whether to show a virtual "working" node at the bottom of the timeline. + /// True when the agent is streaming but no assistant message has appeared yet. + bool _needsWorkingNode(ChatState chatState) { + if (!chatState.isStreaming) return false; + if (chatState.messages.isEmpty) return false; + // Show working node if the last message is still the user's prompt + return chatState.messages.last.role == MessageRole.user; + } + // -- Timeline node builder ------------------------------------------------ Widget _buildTimelineNode( @@ -242,14 +251,31 @@ class _ChatPageState extends ConsumerState { : ListView.builder( controller: _scrollController, padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), - itemCount: chatState.messages.length, + // 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: index == chatState.messages.length - 1 && - !chatState.isStreaming, + isLast: isRealLast && + !chatState.isStreaming && + !_needsWorkingNode(chatState), ); }, ), diff --git a/it0_app/lib/features/chat/presentation/providers/chat_providers.dart b/it0_app/lib/features/chat/presentation/providers/chat_providers.dart index d3b8a89..7664eae 100644 --- a/it0_app/lib/features/chat/presentation/providers/chat_providers.dart +++ b/it0_app/lib/features/chat/presentation/providers/chat_providers.dart @@ -194,6 +194,22 @@ class ChatNotifier extends StateNotifier { ); case ToolResultEvent(:final toolName, :final output, :final isError): + // First, update the matching ToolUse message's status so its spinner + // transitions to a completed/error icon in the timeline. + final updatedMessages = [...state.messages]; + for (int i = updatedMessages.length - 1; i >= 0; i--) { + final m = updatedMessages[i]; + if (m.type == MessageType.toolUse && + m.toolExecution?.status == ToolStatus.executing) { + updatedMessages[i] = m.copyWith( + toolExecution: m.toolExecution!.copyWith( + status: isError ? ToolStatus.error : ToolStatus.completed, + ), + ); + break; + } + } + final msg = ChatMessage( id: DateTime.now().microsecondsSinceEpoch.toString(), role: MessageRole.assistant, @@ -208,7 +224,7 @@ class ChatNotifier extends StateNotifier { status: isError ? ToolStatus.error : ToolStatus.completed, ), ); - state = state.copyWith(messages: [...state.messages, msg]); + state = state.copyWith(messages: [...updatedMessages, msg]); case ApprovalRequiredEvent(:final taskId, :final command, :final riskLevel): final msg = ChatMessage( diff --git a/it0_app/lib/features/chat/presentation/widgets/timeline_event_node.dart b/it0_app/lib/features/chat/presentation/widgets/timeline_event_node.dart index be1f179..c376ab9 100644 --- a/it0_app/lib/features/chat/presentation/widgets/timeline_event_node.dart +++ b/it0_app/lib/features/chat/presentation/widgets/timeline_event_node.dart @@ -1,4 +1,3 @@ -import 'dart:math' as math; import 'package:flutter/material.dart'; import '../../../../core/theme/app_colors.dart'; @@ -132,7 +131,7 @@ class _StaticDot extends StatelessWidget { } } -/// Animated spinning asterisk for active nodes. +/// Animated pulsing asterisk for active nodes (scale up/down like a star). class _SpinnerDot extends StatefulWidget { final Color color; const _SpinnerDot({required this.color}); @@ -150,8 +149,8 @@ class _SpinnerDotState extends State<_SpinnerDot> super.initState(); _controller = AnimationController( vsync: this, - duration: const Duration(milliseconds: 1000), - )..repeat(); + duration: const Duration(milliseconds: 800), + )..repeat(reverse: true); } @override @@ -165,12 +164,14 @@ class _SpinnerDotState extends State<_SpinnerDot> return AnimatedBuilder( animation: _controller, builder: (_, __) { - return Transform.rotate( - angle: _controller.value * 2 * math.pi, + // Pulse between 0.6x and 1.2x scale + final scale = 0.6 + _controller.value * 0.6; + return Transform.scale( + scale: scale, child: Text( '*', style: TextStyle( - color: widget.color, + color: widget.color.withValues(alpha: 0.6 + _controller.value * 0.4), fontSize: 18, fontWeight: FontWeight.bold, height: 1,