From b7814d42a9e74dc08f1e21f356cfe1423c4e847e Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 23 Feb 2026 18:13:30 -0800 Subject: [PATCH] fix: resolve 3 timeline UI bugs (blank start, spinner style, tool status) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 任务开始时空白状态:当 agent streaming 启动但尚无 assistant 响应时,在时间线末尾插入虚拟 "处理中..." 节点(带脉动 * 动画), 避免用户发送 prompt 后界面无任何反馈。 (chat_page.dart: _needsWorkingNode + ListView itemCount+1) 2. * 动画从旋转改为脉动:_SpinnerDot 由 Transform.rotate 改为 Transform.scale(0.6x↔1.2x 缩放 + 透明度 0.6↔1.0 呼吸), duration 从 1000ms 降至 800ms 并启用 reverse,视觉效果类似 星星闪烁而非机械旋转。 (timeline_event_node.dart: _SpinnerDotState) 3. 工具执行完成后状态卡在 spinner:ToolResultEvent 到达时仅创建 新 toolResult 消息,未回溯更新对应 toolUse 消息的 ToolStatus, 导致时间线上工具节点永远显示 executing spinner。修复:在 ToolResultEvent handler 中向前查找最近的 executing 状态的 toolUse 消息,将其 status 更新为 completed/error。 (chat_providers.dart: ToolResultEvent case) Co-Authored-By: Claude Opus 4.6 --- .../chat/presentation/pages/chat_page.dart | 32 +++++++++++++++++-- .../providers/chat_providers.dart | 18 ++++++++++- .../widgets/timeline_event_node.dart | 15 +++++---- 3 files changed, 54 insertions(+), 11 deletions(-) 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,