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:
parent
20325a84bd
commit
b7814d42a9
|
|
@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue