From 20325a84bdfcf73bb805ac74b8063562d395cc26 Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 23 Feb 2026 17:33:42 -0800 Subject: [PATCH] feat: redesign chat UI from bubble style to timeline workflow Replace traditional chat bubble layout with a Claude Code-inspired timeline/workflow design: - Vertical gray line connecting sequential event nodes - Colored dots for each event (green=done, red=error, yellow=warning) - Animated spinning asterisk (*) on active nodes - Streaming text with blinking cursor in timeline nodes - Tool execution shown as code blocks within timeline - User prompts as distinct nodes with person icon New file: timeline_event_node.dart (TimelineEventNode, CodeBlock) Rewritten: chat_page.dart (timeline layout, no more bubbles) Co-Authored-By: Claude Opus 4.6 --- .../chat/presentation/pages/chat_page.dart | 595 ++++++++---------- .../widgets/timeline_event_node.dart | 219 +++++++ 2 files changed, 468 insertions(+), 346 deletions(-) create mode 100644 it0_app/lib/features/chat/presentation/widgets/timeline_event_node.dart 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 614ca4d..3c6ff7e 100644 --- a/it0_app/lib/features/chat/presentation/pages/chat_page.dart +++ b/it0_app/lib/features/chat/presentation/pages/chat_page.dart @@ -3,15 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/theme/app_colors.dart'; import '../../domain/entities/chat_message.dart'; import '../providers/chat_providers.dart'; -import '../widgets/message_bubble.dart'; -import '../widgets/tool_execution_card.dart'; -import '../widgets/approval_action_card.dart'; -import '../widgets/agent_thinking_indicator.dart'; +import '../widgets/timeline_event_node.dart'; import '../widgets/stream_text_widget.dart'; +import '../widgets/approval_action_card.dart'; import '../../../agent_call/presentation/pages/agent_call_page.dart'; // --------------------------------------------------------------------------- -// Chat page – ConsumerStatefulWidget +// Chat page – Timeline workflow style (inspired by Claude Code VSCode) // --------------------------------------------------------------------------- class ChatPage extends ConsumerStatefulWidget { @@ -53,120 +51,127 @@ class _ChatPageState extends ConsumerState { ); } - // -- Message widget dispatch ----------------------------------------------- + // -- Timeline node builder ------------------------------------------------ - Widget _buildMessageWidget(ChatMessage message, ChatState chatState) { + Widget _buildTimelineNode( + ChatMessage message, + ChatState chatState, { + required bool isFirst, + required bool isLast, + }) { final isLastMessage = chatState.messages.last.id == message.id; final isStreamingNow = isLastMessage && chatState.isStreaming; + // User message — special "prompt" node + if (message.role == MessageRole.user) { + return TimelineEventNode( + status: NodeStatus.completed, + label: message.content, + isFirst: isFirst, + isLast: isLast, + icon: Icons.person_outline, + ); + } + switch (message.type) { - case MessageType.toolUse: - case MessageType.toolResult: - if (message.toolExecution != null) { - return ToolExecutionCard(toolExecution: message.toolExecution!); - } - return MessageBubble(message: message); - - case MessageType.approval: - if (message.approvalRequest != null) { - return ApprovalActionCard( - approvalRequest: message.approvalRequest!, - onApprove: () => ref.read(chatProvider.notifier) - .approveCommand(message.approvalRequest!.taskId), - onReject: (reason) => ref.read(chatProvider.notifier) - .rejectCommand(message.approvalRequest!.taskId, reason: reason), - ); - } - return MessageBubble(message: message); - - case MessageType.standingOrderDraft: - return _StandingOrderDraftCard( - draft: message.metadata ?? {}, - onConfirm: () => ref.read(chatProvider.notifier) - .confirmStandingOrder(message.metadata ?? {}), + case MessageType.thinking: + return TimelineEventNode( + status: isStreamingNow ? NodeStatus.active : NodeStatus.completed, + label: '思考中...', + isFirst: isFirst, + isLast: isLast, + content: StreamTextWidget( + text: message.content, + isStreaming: isStreamingNow, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 13, + fontStyle: FontStyle.italic, + ), + ), ); - case MessageType.thinking: - // AI thinking content — italic label + streaming text - return Align( - alignment: Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.symmetric(vertical: 4), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.78, - ), - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - bottomLeft: Radius.circular(4), - bottomRight: Radius.circular(16), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '思考中...', - style: TextStyle( - color: AppColors.textMuted, - fontSize: 11, - fontStyle: FontStyle.italic, - ), - ), - const SizedBox(height: 4), - StreamTextWidget( - text: message.content, - isStreaming: isStreamingNow, - style: TextStyle( - color: AppColors.textSecondary, - fontSize: 14, - ), - ), - ], - ), + case MessageType.toolUse: + final tool = message.toolExecution; + final toolName = tool?.toolName ?? 'unknown'; + final isExecuting = tool?.status == ToolStatus.executing; + return TimelineEventNode( + status: isExecuting ? NodeStatus.active : NodeStatus.completed, + label: toolName, + isFirst: isFirst, + isLast: isLast, + icon: isExecuting ? null : Icons.check_circle_outline, + content: tool != null && tool.input.isNotEmpty + ? CodeBlock(text: tool.input) + : null, + ); + + case MessageType.toolResult: + final tool = message.toolExecution; + final isError = tool?.status == ToolStatus.error; + return TimelineEventNode( + status: isError ? NodeStatus.error : NodeStatus.completed, + label: isError ? '执行失败' : '执行结果', + isFirst: isFirst, + isLast: isLast, + icon: isError ? Icons.cancel_outlined : Icons.check_circle_outline, + content: tool?.output != null && tool!.output!.isNotEmpty + ? CodeBlock( + text: tool.output!, + textColor: isError ? AppColors.error : null, + ) + : null, + ); + + case MessageType.approval: + return TimelineEventNode( + status: NodeStatus.warning, + label: '需要审批', + isFirst: isFirst, + isLast: isLast, + icon: Icons.shield_outlined, + content: message.approvalRequest != null + ? ApprovalActionCard( + approvalRequest: message.approvalRequest!, + onApprove: () => ref.read(chatProvider.notifier) + .approveCommand(message.approvalRequest!.taskId), + onReject: (reason) => ref.read(chatProvider.notifier) + .rejectCommand(message.approvalRequest!.taskId, reason: reason), + ) + : null, + ); + + case MessageType.standingOrderDraft: + return TimelineEventNode( + status: NodeStatus.warning, + label: '常驻指令草案', + isFirst: isFirst, + isLast: isLast, + icon: Icons.schedule, + content: _StandingOrderContent( + draft: message.metadata ?? {}, + onConfirm: () => ref.read(chatProvider.notifier) + .confirmStandingOrder(message.metadata ?? {}), ), ); case MessageType.text: default: - // User messages → standard bubble - if (message.role == MessageRole.user) { - return MessageBubble(message: message); - } - // AI text messages — use StreamTextWidget when actively streaming - if (isStreamingNow) { - return Align( - alignment: Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.symmetric(vertical: 4), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.78, - ), - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - bottomLeft: Radius.circular(4), - bottomRight: Radius.circular(16), - ), - ), - child: StreamTextWidget( - text: message.content, - isStreaming: true, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 15, - ), - ), + return TimelineEventNode( + status: isStreamingNow ? NodeStatus.active : NodeStatus.completed, + label: isStreamingNow ? '回复中...' : '回复', + isFirst: isFirst, + isLast: isLast, + icon: isStreamingNow ? null : Icons.check_circle_outline, + content: StreamTextWidget( + text: message.content, + isStreaming: isStreamingNow, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, ), - ); - } - return MessageBubble(message: message); + ), + ); } } @@ -181,7 +186,7 @@ class _ChatPageState extends ConsumerState { return Scaffold( appBar: AppBar( - title: const Text('AI 对话'), + title: const Text('iAgent'), actions: [ // Stop button during streaming if (chatState.isStreaming) @@ -208,67 +213,43 @@ class _ChatPageState extends ConsumerState { children: [ // Error banner if (chatState.error != null) - MaterialBanner( - content: Text(chatState.error!, style: const TextStyle(color: AppColors.error)), - backgroundColor: AppColors.error.withOpacity(0.1), - actions: [ - TextButton( - onPressed: () => - ref.read(chatProvider.notifier).clearChat(), - child: const Text('关闭'), - ), - ], + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + color: AppColors.error.withOpacity(0.1), + child: Row( + children: [ + const Icon(Icons.error_outline, size: 16, color: AppColors.error), + const SizedBox(width: 8), + Expanded( + child: Text( + chatState.error!, + style: const TextStyle(color: AppColors.error, fontSize: 13), + ), + ), + GestureDetector( + onTap: () => ref.read(chatProvider.notifier).clearChat(), + child: const Icon(Icons.close, size: 16, color: AppColors.error), + ), + ], + ), ), - // Agent status bar - if (chatState.agentStatus != AgentStatus.idle && chatState.agentStatus != AgentStatus.error) - _AgentStatusBar(status: chatState.agentStatus), - - // Message list + // Timeline message list Expanded( child: chatState.messages.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.smart_toy_outlined, size: 64, color: AppColors.textMuted), - const SizedBox(height: 16), - Text( - '开始与 iAgent 对话', - style: TextStyle(color: AppColors.textSecondary, fontSize: 16), - ), - const SizedBox(height: 8), - Text( - '输入文字或拨打语音通话', - style: TextStyle(color: AppColors.textMuted, fontSize: 13), - ), - const SizedBox(height: 24), - OutlinedButton.icon( - onPressed: _openVoiceCall, - icon: const Icon(Icons.call), - label: const Text('语音通话'), - style: OutlinedButton.styleFrom( - foregroundColor: AppColors.success, - side: const BorderSide(color: AppColors.success), - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - ), - ), - ], - ), - ) + ? _buildEmptyState() : ListView.builder( controller: _scrollController, padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), - itemCount: chatState.messages.length + - (chatState.agentStatus == AgentStatus.thinking ? 1 : 0), + itemCount: chatState.messages.length, itemBuilder: (context, index) { - // Trailing thinking indicator - if (index == chatState.messages.length) { - return const AgentThinkingIndicator(); - } - return _buildMessageWidget( + return _buildTimelineNode( chatState.messages[index], chatState, + isFirst: index == 0, + isLast: index == chatState.messages.length - 1 && + !chatState.isStreaming, ); }, ), @@ -281,8 +262,41 @@ class _ChatPageState extends ConsumerState { ); } + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.smart_toy_outlined, size: 64, color: AppColors.textMuted), + const SizedBox(height: 16), + Text( + '开始与 iAgent 对话', + style: TextStyle(color: AppColors.textSecondary, fontSize: 16), + ), + const SizedBox(height: 8), + Text( + '输入指令或拨打语音通话', + style: TextStyle(color: AppColors.textMuted, fontSize: 13), + ), + const SizedBox(height: 24), + OutlinedButton.icon( + onPressed: _openVoiceCall, + icon: const Icon(Icons.call), + label: const Text('语音通话'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.success, + side: const BorderSide(color: AppColors.success), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + ], + ), + ); + } + Widget _buildInputArea(ChatState chatState) { - final isDisabled = chatState.isStreaming || chatState.agentStatus == AgentStatus.awaitingApproval; + final isDisabled = chatState.isStreaming || + chatState.agentStatus == AgentStatus.awaitingApproval; return Container( padding: const EdgeInsets.all(8), @@ -291,48 +305,35 @@ class _ChatPageState extends ConsumerState { border: Border(top: BorderSide(color: AppColors.surfaceLight.withOpacity(0.5))), ), child: SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, + child: Row( children: [ - if (chatState.agentStatus == AgentStatus.awaitingApproval) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - '等待审批中...', - style: TextStyle(color: AppColors.warning, fontSize: 12), + Expanded( + child: TextField( + controller: _messageController, + decoration: const InputDecoration( + hintText: '输入指令...', + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(24)), + ), + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 10), ), + textInputAction: TextInputAction.send, + onSubmitted: (_) => _send(), + enabled: !isDisabled, ), - Row( - children: [ - Expanded( - child: TextField( - controller: _messageController, - decoration: const InputDecoration( - hintText: '向 iAgent 提问...', - border: OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(24)), - ), - contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 10), - ), - textInputAction: TextInputAction.send, - onSubmitted: (_) => _send(), - enabled: !isDisabled, - ), - ), - const SizedBox(width: 8), - if (chatState.isStreaming) - IconButton( - icon: const Icon(Icons.stop_circle_outlined, color: AppColors.error), - tooltip: '停止', - onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(), - ) - else - IconButton( - icon: const Icon(Icons.send), - onPressed: isDisabled ? null : _send, - ), - ], ), + const SizedBox(width: 8), + if (chatState.isStreaming) + IconButton( + icon: const Icon(Icons.stop_circle_outlined, color: AppColors.error), + tooltip: '停止', + onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(), + ) + else + IconButton( + icon: const Icon(Icons.send), + onPressed: isDisabled ? null : _send, + ), ], ), ), @@ -348,57 +349,14 @@ class _ChatPageState extends ConsumerState { } // --------------------------------------------------------------------------- -// Agent status bar +// Standing order content (embedded in timeline node) // --------------------------------------------------------------------------- -class _AgentStatusBar extends StatelessWidget { - final AgentStatus status; - const _AgentStatusBar({required this.status}); - - @override - Widget build(BuildContext context) { - final (String label, Color color, IconData icon) = switch (status) { - AgentStatus.thinking => ('AI 正在思考...', AppColors.info, Icons.psychology), - AgentStatus.executing => ('正在执行命令...', AppColors.primary, Icons.terminal), - AgentStatus.awaitingApproval => ('等待审批', AppColors.warning, Icons.shield_outlined), - _ => ('', AppColors.textMuted, Icons.info_outline), - }; - - if (label.isEmpty) return const SizedBox.shrink(); - - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - color: color.withOpacity(0.1), - child: Row( - children: [ - SizedBox( - width: 16, - height: 16, - child: status == AgentStatus.executing - ? CircularProgressIndicator(strokeWidth: 2, color: color) - : Icon(icon, size: 16, color: color), - ), - const SizedBox(width: 8), - Text( - label, - style: TextStyle(color: color, fontSize: 13, fontWeight: FontWeight.w500), - ), - ], - ), - ); - } -} - -// --------------------------------------------------------------------------- -// Standing order draft card -// --------------------------------------------------------------------------- - -class _StandingOrderDraftCard extends StatelessWidget { +class _StandingOrderContent extends StatelessWidget { final Map draft; final VoidCallback onConfirm; - const _StandingOrderDraftCard({ + const _StandingOrderContent({ required this.draft, required this.onConfirm, }); @@ -410,112 +368,57 @@ class _StandingOrderDraftCard extends StatelessWidget { final command = draft['command'] as String? ?? draft['prompt'] as String? ?? ''; final targets = draft['targets'] as List? ?? draft['servers'] as List? ?? []; - return Container( - margin: const EdgeInsets.symmetric(vertical: 6), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.primary.withOpacity(0.4)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Row( - children: [ - Icon(Icons.schedule, size: 18, color: AppColors.primary), - const SizedBox(width: 8), - Expanded( - child: Text( - '常驻指令草案', - style: TextStyle( - fontWeight: FontWeight.w600, - fontSize: 14, - color: AppColors.primary, - ), - ), - ), - ], - ), - - const SizedBox(height: 10), - - // Name - Text(name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15)), - - if (cron.isNotEmpty) ...[ - const SizedBox(height: 6), - Row( - children: [ - const Icon(Icons.timer_outlined, size: 14, color: AppColors.textMuted), - const SizedBox(width: 6), - Text(cron, style: const TextStyle(color: AppColors.textSecondary, fontSize: 12, fontFamily: 'monospace')), - ], + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)), + if (cron.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + cron, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + fontFamily: 'monospace', ), - ], - - if (command.isNotEmpty) ...[ - const SizedBox(height: 8), - Container( - width: double.infinity, - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppColors.background, - borderRadius: BorderRadius.circular(8), - ), - child: SelectableText( - command, - style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppColors.textSecondary), - ), - ), - ], - - if (targets.isNotEmpty) ...[ - const SizedBox(height: 6), - Row( - children: [ - const Icon(Icons.dns, size: 14, color: AppColors.textMuted), - const SizedBox(width: 6), - Text( - '目标: ${targets.join(", ")}', - style: const TextStyle(color: AppColors.textSecondary, fontSize: 12), - ), - ], - ), - ], - - const SizedBox(height: 12), - - // Action buttons - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - OutlinedButton( - onPressed: () { - // Dismiss — no specific API call needed - }, - style: OutlinedButton.styleFrom( - foregroundColor: AppColors.textSecondary, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - minimumSize: Size.zero, - ), - child: const Text('取消', style: TextStyle(fontSize: 13)), - ), - const SizedBox(width: 8), - FilledButton( - onPressed: onConfirm, - style: FilledButton.styleFrom( - backgroundColor: AppColors.primary, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - minimumSize: Size.zero, - ), - child: const Text('确认', style: TextStyle(fontSize: 13)), - ), - ], ), ], - ), + if (command.isNotEmpty) ...[ + const SizedBox(height: 6), + CodeBlock(text: command), + ], + if (targets.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + '目标: ${targets.join(", ")}', + style: const TextStyle(color: AppColors.textSecondary, fontSize: 12), + ), + ], + const SizedBox(height: 8), + Row( + children: [ + OutlinedButton( + onPressed: () {}, + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.textSecondary, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + minimumSize: Size.zero, + ), + child: const Text('取消', style: TextStyle(fontSize: 12)), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: onConfirm, + style: FilledButton.styleFrom( + backgroundColor: AppColors.primary, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + minimumSize: Size.zero, + ), + child: const Text('确认', style: TextStyle(fontSize: 12)), + ), + ], + ), + ], ); } } 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 new file mode 100644 index 0000000..be1f179 --- /dev/null +++ b/it0_app/lib/features/chat/presentation/widgets/timeline_event_node.dart @@ -0,0 +1,219 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; + +/// Status of a timeline node, determines dot style and color. +enum NodeStatus { active, completed, error, warning, idle } + +/// A single node in the agent workflow timeline. +/// +/// Renders: vertical line | colored dot/spinner | content +/// When [status] is [NodeStatus.active], the dot becomes an animated spinner. +class TimelineEventNode extends StatelessWidget { + final NodeStatus status; + final String label; + final Widget? content; + final bool isFirst; + final bool isLast; + final bool isStreaming; + final IconData? icon; + + const TimelineEventNode({ + super.key, + required this.status, + required this.label, + this.content, + this.isFirst = false, + this.isLast = false, + this.isStreaming = false, + this.icon, + }); + + Color get _dotColor => switch (status) { + NodeStatus.active => AppColors.info, + NodeStatus.completed => AppColors.success, + NodeStatus.error => AppColors.error, + NodeStatus.warning => AppColors.warning, + NodeStatus.idle => AppColors.textMuted, + }; + + @override + Widget build(BuildContext context) { + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Left column: vertical line + dot + SizedBox( + width: 28, + child: Column( + children: [ + // Line above dot + Container( + width: 1.5, + height: 8, + color: isFirst ? Colors.transparent : AppColors.surfaceLight, + ), + // Dot or spinner + status == NodeStatus.active + ? _SpinnerDot(color: _dotColor) + : _StaticDot(color: _dotColor, icon: icon), + // Line below dot + Expanded( + child: Container( + width: 1.5, + color: isLast ? Colors.transparent : AppColors.surfaceLight, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + // Right column: label + content + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Label row + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + label, + style: TextStyle( + color: status == NodeStatus.active + ? _dotColor + : AppColors.textSecondary, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + // Content + if (content != null) ...[ + const SizedBox(height: 6), + content!, + ], + ], + ), + ), + ), + ], + ), + ); + } +} + +/// Static colored dot with optional icon for completed/error/idle states. +class _StaticDot extends StatelessWidget { + final Color color; + final IconData? icon; + + const _StaticDot({required this.color, this.icon}); + + @override + Widget build(BuildContext context) { + if (icon != null) { + return SizedBox( + width: 16, + height: 16, + child: Icon(icon, size: 14, color: color), + ); + } + return Container( + width: 10, + height: 10, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + ), + ); + } +} + +/// Animated spinning asterisk for active nodes. +class _SpinnerDot extends StatefulWidget { + final Color color; + const _SpinnerDot({required this.color}); + + @override + State<_SpinnerDot> createState() => _SpinnerDotState(); +} + +class _SpinnerDotState extends State<_SpinnerDot> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1000), + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (_, __) { + return Transform.rotate( + angle: _controller.value * 2 * math.pi, + child: Text( + '*', + style: TextStyle( + color: widget.color, + fontSize: 18, + fontWeight: FontWeight.bold, + height: 1, + ), + ), + ); + }, + ); + } +} + +/// Code block container used for tool input/output. +class CodeBlock extends StatelessWidget { + final String text; + final Color? textColor; + final int? maxLines; + + const CodeBlock({ + super.key, + required this.text, + this.textColor, + this.maxLines, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(6), + ), + child: SelectableText( + text, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: textColor ?? AppColors.textSecondary, + height: 1.4, + ), + maxLines: maxLines, + ), + ); + } +}