From b7aa8f9ddaee9cd4e397192f73ddb0017129830b Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Mon, 8 Dec 2025 10:41:27 +0530 Subject: [PATCH] feat(chat): add shimmer effect for streaming tool calls and reasoning --- .../widgets/assistant_message_widget.dart | 132 +++++++++++------- .../chat/widgets/streaming_status_widget.dart | 74 ++-------- 2 files changed, 95 insertions(+), 111 deletions(-) diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index 93b9ead..47c34c7 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -380,6 +380,8 @@ class _AssistantMessageWidgetState extends ConsumerState Widget _buildToolCallTile(ToolCallEntry tc) { final isExpanded = _expandedToolIds.contains(tc.id); final theme = context.conduitTheme; + // Show shimmer when streaming and tool call is not done + final showShimmer = widget.isStreaming && !tc.done; String pretty(dynamic v, {int max = 1200}) { try { @@ -393,6 +395,44 @@ class _AssistantMessageWidgetState extends ConsumerState } } + Widget buildHeader() { + final headerWidget = Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + isExpanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + size: 14, + color: theme.textPrimary.withValues(alpha: 0.8), + ), + const SizedBox(width: 2), + Flexible( + child: Text( + tc.done ? 'Used ${tc.name}' : 'Running ${tc.name}…', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: AppTypography.bodySmall, + color: theme.textPrimary.withValues(alpha: 0.8), + height: 1.3, + ), + ), + ), + ], + ); + + if (showShimmer) { + return headerWidget + .animate(onPlay: (controller) => controller.repeat()) + .shimmer( + duration: 1500.ms, + color: theme.shimmerHighlight.withValues(alpha: 0.6), + ); + } + return headerWidget; + } + return Padding( padding: const EdgeInsets.only(bottom: Spacing.xs), child: GestureDetector( @@ -411,31 +451,7 @@ class _AssistantMessageWidgetState extends ConsumerState mainAxisSize: MainAxisSize.min, children: [ // Minimal header - just text with chevron - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon( - isExpanded - ? Icons.keyboard_arrow_up_rounded - : Icons.keyboard_arrow_down_rounded, - size: 14, - color: theme.textPrimary.withValues(alpha: 0.8), - ), - const SizedBox(width: 2), - Flexible( - child: Text( - tc.done ? 'Used ${tc.name}' : 'Running ${tc.name}…', - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: AppTypography.bodySmall, - color: theme.textPrimary.withValues(alpha: 0.8), - height: 1.3, - ), - ), - ), - ], - ), + buildHeader(), // Expanded content with left border accent AnimatedCrossFade( @@ -1304,6 +1320,8 @@ class _AssistantMessageWidgetState extends ConsumerState Widget _buildReasoningTile(ReasoningEntry rc, int index) { final isExpanded = _expandedReasoning.contains(index); final theme = context.conduitTheme; + // Show shimmer when streaming and this is an active/incomplete reasoning + final showShimmer = widget.isStreaming && rc.duration == 0; String headerText() { final l10n = AppLocalizations.of(context)!; @@ -1323,6 +1341,44 @@ class _AssistantMessageWidgetState extends ConsumerState return rc.summary; } + Widget buildHeader() { + final headerWidget = Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + isExpanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + size: 14, + color: theme.textPrimary.withValues(alpha: 0.8), + ), + const SizedBox(width: 2), + Flexible( + child: Text( + headerText(), + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: AppTypography.bodySmall, + color: theme.textPrimary.withValues(alpha: 0.8), + height: 1.3, + ), + ), + ), + ], + ); + + if (showShimmer) { + return headerWidget + .animate(onPlay: (controller) => controller.repeat()) + .shimmer( + duration: 1500.ms, + color: theme.shimmerHighlight.withValues(alpha: 0.6), + ); + } + return headerWidget; + } + return Padding( padding: const EdgeInsets.only(bottom: Spacing.xs), child: GestureDetector( @@ -1341,31 +1397,7 @@ class _AssistantMessageWidgetState extends ConsumerState mainAxisSize: MainAxisSize.min, children: [ // Minimal header - just text with chevron - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon( - isExpanded - ? Icons.keyboard_arrow_up_rounded - : Icons.keyboard_arrow_down_rounded, - size: 14, - color: theme.textPrimary.withValues(alpha: 0.8), - ), - const SizedBox(width: 2), - Flexible( - child: Text( - headerText(), - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: AppTypography.bodySmall, - color: theme.textPrimary.withValues(alpha: 0.8), - height: 1.3, - ), - ), - ), - ], - ), + buildHeader(), // Expanded content - subtle background only when shown AnimatedCrossFade( diff --git a/lib/features/chat/widgets/streaming_status_widget.dart b/lib/features/chat/widgets/streaming_status_widget.dart index d966eda..6dfcee4 100644 --- a/lib/features/chat/widgets/streaming_status_widget.dart +++ b/lib/features/chat/widgets/streaming_status_widget.dart @@ -23,38 +23,8 @@ class StreamingStatusWidget extends StatefulWidget { State createState() => _StreamingStatusWidgetState(); } -class _StreamingStatusWidgetState extends State - with SingleTickerProviderStateMixin { +class _StreamingStatusWidgetState extends State { bool _expanded = false; - late AnimationController _shimmerController; - - @override - void initState() { - super.initState(); - _shimmerController = AnimationController( - duration: const Duration(milliseconds: 1500), - vsync: this, - ); - if (widget.isStreaming) { - _shimmerController.repeat(); - } - } - - @override - void didUpdateWidget(covariant StreamingStatusWidget oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.isStreaming && !_shimmerController.isAnimating) { - _shimmerController.repeat(); - } else if (!widget.isStreaming && _shimmerController.isAnimating) { - _shimmerController.stop(); - } - } - - @override - void dispose() { - _shimmerController.dispose(); - super.dispose(); - } @override Widget build(BuildContext context) { @@ -80,7 +50,6 @@ class _StreamingStatusWidgetState extends State _MinimalStatusRow( update: current, isPending: isPending, - shimmerController: _shimmerController, hasPrevious: hasPrevious, isExpanded: _expanded, ), @@ -89,7 +58,6 @@ class _StreamingStatusWidgetState extends State if (_expanded && hasPrevious) _MinimalHistoryTimeline( updates: visible, - shimmerController: _shimmerController, isStreaming: widget.isStreaming, ), ], @@ -104,14 +72,12 @@ class _MinimalStatusRow extends StatelessWidget { const _MinimalStatusRow({ required this.update, required this.isPending, - required this.shimmerController, required this.hasPrevious, required this.isExpanded, }); final ChatStatusUpdate update; final bool isPending; - final AnimationController shimmerController; final bool hasPrevious; final bool isExpanded; @@ -177,20 +143,13 @@ class _MinimalStatusRow extends StatelessWidget { return Text(description, style: baseStyle, maxLines: 1); } - // Subtle shimmer for pending state - return AnimatedBuilder( - animation: shimmerController, - builder: (context, child) { - final opacity = 0.6 + (0.4 * (1.0 - shimmerController.value)); - return Text( - description, - style: baseStyle.copyWith( - color: baseColor.withValues(alpha: opacity), - ), - maxLines: 1, + // Shimmer effect for pending state + return Text(description, style: baseStyle, maxLines: 1) + .animate(onPlay: (controller) => controller.repeat()) + .shimmer( + duration: 1500.ms, + color: theme.shimmerHighlight.withValues(alpha: 0.6), ); - }, - ); } } @@ -198,12 +157,10 @@ class _MinimalStatusRow extends StatelessWidget { class _MinimalHistoryTimeline extends StatelessWidget { const _MinimalHistoryTimeline({ required this.updates, - required this.shimmerController, required this.isStreaming, }); final List updates; - final AnimationController shimmerController; final bool isStreaming; @override @@ -300,18 +257,13 @@ class _MinimalHistoryTimeline extends StatelessWidget { return Text(description, style: baseStyle); } - return AnimatedBuilder( - animation: shimmerController, - builder: (context, child) { - final opacity = 0.6 + (0.4 * (1.0 - shimmerController.value)); - return Text( - description, - style: baseStyle.copyWith( - color: baseColor.withValues(alpha: opacity), - ), + // Shimmer effect for pending state + return Text(description, style: baseStyle) + .animate(onPlay: (controller) => controller.repeat()) + .shimmer( + duration: 1500.ms, + color: theme.shimmerHighlight.withValues(alpha: 0.6), ); - }, - ); } }