feat(chat): add shimmer effect for streaming tool calls and reasoning

This commit is contained in:
cogwheel0
2025-12-08 10:41:27 +05:30
parent d4797decc7
commit b7aa8f9dda
2 changed files with 95 additions and 111 deletions

View File

@@ -380,6 +380,8 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
Widget _buildToolCallTile(ToolCallEntry tc) { Widget _buildToolCallTile(ToolCallEntry tc) {
final isExpanded = _expandedToolIds.contains(tc.id); final isExpanded = _expandedToolIds.contains(tc.id);
final theme = context.conduitTheme; 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}) { String pretty(dynamic v, {int max = 1200}) {
try { try {
@@ -393,6 +395,44 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
} }
} }
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( return Padding(
padding: const EdgeInsets.only(bottom: Spacing.xs), padding: const EdgeInsets.only(bottom: Spacing.xs),
child: GestureDetector( child: GestureDetector(
@@ -411,31 +451,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Minimal header - just text with chevron // Minimal header - just text with chevron
Row( buildHeader(),
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,
),
),
),
],
),
// Expanded content with left border accent // Expanded content with left border accent
AnimatedCrossFade( AnimatedCrossFade(
@@ -1304,6 +1320,8 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
Widget _buildReasoningTile(ReasoningEntry rc, int index) { Widget _buildReasoningTile(ReasoningEntry rc, int index) {
final isExpanded = _expandedReasoning.contains(index); final isExpanded = _expandedReasoning.contains(index);
final theme = context.conduitTheme; final theme = context.conduitTheme;
// Show shimmer when streaming and this is an active/incomplete reasoning
final showShimmer = widget.isStreaming && rc.duration == 0;
String headerText() { String headerText() {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
@@ -1323,6 +1341,44 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
return rc.summary; 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( return Padding(
padding: const EdgeInsets.only(bottom: Spacing.xs), padding: const EdgeInsets.only(bottom: Spacing.xs),
child: GestureDetector( child: GestureDetector(
@@ -1341,31 +1397,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Minimal header - just text with chevron // Minimal header - just text with chevron
Row( buildHeader(),
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,
),
),
),
],
),
// Expanded content - subtle background only when shown // Expanded content - subtle background only when shown
AnimatedCrossFade( AnimatedCrossFade(

View File

@@ -23,38 +23,8 @@ class StreamingStatusWidget extends StatefulWidget {
State<StreamingStatusWidget> createState() => _StreamingStatusWidgetState(); State<StreamingStatusWidget> createState() => _StreamingStatusWidgetState();
} }
class _StreamingStatusWidgetState extends State<StreamingStatusWidget> class _StreamingStatusWidgetState extends State<StreamingStatusWidget> {
with SingleTickerProviderStateMixin {
bool _expanded = false; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -80,7 +50,6 @@ class _StreamingStatusWidgetState extends State<StreamingStatusWidget>
_MinimalStatusRow( _MinimalStatusRow(
update: current, update: current,
isPending: isPending, isPending: isPending,
shimmerController: _shimmerController,
hasPrevious: hasPrevious, hasPrevious: hasPrevious,
isExpanded: _expanded, isExpanded: _expanded,
), ),
@@ -89,7 +58,6 @@ class _StreamingStatusWidgetState extends State<StreamingStatusWidget>
if (_expanded && hasPrevious) if (_expanded && hasPrevious)
_MinimalHistoryTimeline( _MinimalHistoryTimeline(
updates: visible, updates: visible,
shimmerController: _shimmerController,
isStreaming: widget.isStreaming, isStreaming: widget.isStreaming,
), ),
], ],
@@ -104,14 +72,12 @@ class _MinimalStatusRow extends StatelessWidget {
const _MinimalStatusRow({ const _MinimalStatusRow({
required this.update, required this.update,
required this.isPending, required this.isPending,
required this.shimmerController,
required this.hasPrevious, required this.hasPrevious,
required this.isExpanded, required this.isExpanded,
}); });
final ChatStatusUpdate update; final ChatStatusUpdate update;
final bool isPending; final bool isPending;
final AnimationController shimmerController;
final bool hasPrevious; final bool hasPrevious;
final bool isExpanded; final bool isExpanded;
@@ -177,20 +143,13 @@ class _MinimalStatusRow extends StatelessWidget {
return Text(description, style: baseStyle, maxLines: 1); return Text(description, style: baseStyle, maxLines: 1);
} }
// Subtle shimmer for pending state // Shimmer effect for pending state
return AnimatedBuilder( return Text(description, style: baseStyle, maxLines: 1)
animation: shimmerController, .animate(onPlay: (controller) => controller.repeat())
builder: (context, child) { .shimmer(
final opacity = 0.6 + (0.4 * (1.0 - shimmerController.value)); duration: 1500.ms,
return Text( color: theme.shimmerHighlight.withValues(alpha: 0.6),
description,
style: baseStyle.copyWith(
color: baseColor.withValues(alpha: opacity),
),
maxLines: 1,
); );
},
);
} }
} }
@@ -198,12 +157,10 @@ class _MinimalStatusRow extends StatelessWidget {
class _MinimalHistoryTimeline extends StatelessWidget { class _MinimalHistoryTimeline extends StatelessWidget {
const _MinimalHistoryTimeline({ const _MinimalHistoryTimeline({
required this.updates, required this.updates,
required this.shimmerController,
required this.isStreaming, required this.isStreaming,
}); });
final List<ChatStatusUpdate> updates; final List<ChatStatusUpdate> updates;
final AnimationController shimmerController;
final bool isStreaming; final bool isStreaming;
@override @override
@@ -300,18 +257,13 @@ class _MinimalHistoryTimeline extends StatelessWidget {
return Text(description, style: baseStyle); return Text(description, style: baseStyle);
} }
return AnimatedBuilder( // Shimmer effect for pending state
animation: shimmerController, return Text(description, style: baseStyle)
builder: (context, child) { .animate(onPlay: (controller) => controller.repeat())
final opacity = 0.6 + (0.4 * (1.0 - shimmerController.value)); .shimmer(
return Text( duration: 1500.ms,
description, color: theme.shimmerHighlight.withValues(alpha: 0.6),
style: baseStyle.copyWith(
color: baseColor.withValues(alpha: opacity),
),
); );
},
);
} }
} }