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 ------------------------------------------------
|
// -- Timeline node builder ------------------------------------------------
|
||||||
|
|
||||||
Widget _buildTimelineNode(
|
Widget _buildTimelineNode(
|
||||||
|
|
@ -242,14 +251,31 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
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) {
|
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(
|
return _buildTimelineNode(
|
||||||
chatState.messages[index],
|
chatState.messages[index],
|
||||||
chatState,
|
chatState,
|
||||||
isFirst: index == 0,
|
isFirst: index == 0,
|
||||||
isLast: index == chatState.messages.length - 1 &&
|
isLast: isRealLast &&
|
||||||
!chatState.isStreaming,
|
!chatState.isStreaming &&
|
||||||
|
!_needsWorkingNode(chatState),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,22 @@ class ChatNotifier extends StateNotifier<ChatState> {
|
||||||
);
|
);
|
||||||
|
|
||||||
case ToolResultEvent(:final toolName, :final output, :final isError):
|
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(
|
final msg = ChatMessage(
|
||||||
id: DateTime.now().microsecondsSinceEpoch.toString(),
|
id: DateTime.now().microsecondsSinceEpoch.toString(),
|
||||||
role: MessageRole.assistant,
|
role: MessageRole.assistant,
|
||||||
|
|
@ -208,7 +224,7 @@ class ChatNotifier extends StateNotifier<ChatState> {
|
||||||
status: isError ? ToolStatus.error : ToolStatus.completed,
|
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):
|
case ApprovalRequiredEvent(:final taskId, :final command, :final riskLevel):
|
||||||
final msg = ChatMessage(
|
final msg = ChatMessage(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import 'dart:math' as math;
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../../core/theme/app_colors.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 {
|
class _SpinnerDot extends StatefulWidget {
|
||||||
final Color color;
|
final Color color;
|
||||||
const _SpinnerDot({required this.color});
|
const _SpinnerDot({required this.color});
|
||||||
|
|
@ -150,8 +149,8 @@ class _SpinnerDotState extends State<_SpinnerDot>
|
||||||
super.initState();
|
super.initState();
|
||||||
_controller = AnimationController(
|
_controller = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 1000),
|
duration: const Duration(milliseconds: 800),
|
||||||
)..repeat();
|
)..repeat(reverse: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -165,12 +164,14 @@ class _SpinnerDotState extends State<_SpinnerDot>
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: _controller,
|
animation: _controller,
|
||||||
builder: (_, __) {
|
builder: (_, __) {
|
||||||
return Transform.rotate(
|
// Pulse between 0.6x and 1.2x scale
|
||||||
angle: _controller.value * 2 * math.pi,
|
final scale = 0.6 + _controller.value * 0.6;
|
||||||
|
return Transform.scale(
|
||||||
|
scale: scale,
|
||||||
child: Text(
|
child: Text(
|
||||||
'*',
|
'*',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: widget.color,
|
color: widget.color.withValues(alpha: 0.6 + _controller.value * 0.4),
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
height: 1,
|
height: 1,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue