fix: resolve 3 timeline UI bugs (blank start, spinner style, tool status)

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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-23 18:13:30 -08:00
parent 20325a84bd
commit b7814d42a9
3 changed files with 54 additions and 11 deletions

View File

@ -51,6 +51,15 @@ class _ChatPageState extends ConsumerState<ChatPage> {
);
}
/// 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<ChatPage> {
: 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),
);
},
),

View File

@ -194,6 +194,22 @@ class ChatNotifier extends StateNotifier<ChatState> {
);
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<ChatState> {
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(

View File

@ -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,