diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index d3c23ad..d619ea3 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -190,21 +190,27 @@ class _AssistantMessageWidgetState extends ConsumerState } void _updateTypingIndicatorGate() { - // Show typing indicator while streaming until we have any renderable segments - // (tool tiles or actual text). Use a short delay to avoid flicker. _typingGateTimer?.cancel(); - final hasRenderable = _hasRenderableSegments; - if (widget.isStreaming && !hasRenderable) { - _allowTypingIndicator = false; + if (_shouldShowTypingIndicator) { + if (_allowTypingIndicator) { + return; + } _typingGateTimer = Timer(const Duration(milliseconds: 150), () { - if (mounted) { - setState(() { - _allowTypingIndicator = true; - }); + if (!mounted || !_shouldShowTypingIndicator) { + return; } + setState(() { + _allowTypingIndicator = true; + }); }); - } else { - _allowTypingIndicator = false; + } else if (_allowTypingIndicator) { + if (mounted) { + setState(() { + _allowTypingIndicator = false; + }); + } else { + _allowTypingIndicator = false; + } } } @@ -467,45 +473,39 @@ class _AssistantMessageWidgetState extends ConsumerState ); } - bool get _hasRenderableSegments { - bool textRenderable(String t) { - String cleaned = t; - // Hide tool_calls blocks entirely - cleaned = cleaned.replaceAll( - RegExp( - r']*>[\s\S]*?<\/details>', - multiLine: true, - dotAll: true, - ), - '', - ); - // Hide reasoning blocks as well in text check - cleaned = cleaned.replaceAll( - RegExp( - r']*>[\s\S]*?<\/details>', - multiLine: true, - dotAll: true, - ), - '', - ); - // If last
is unclosed, drop tail to avoid rendering raw tag - final lastOpen = cleaned.lastIndexOf('= 0) { - final tail = cleaned.substring(lastOpen); - if (!tail.contains('
')) { - cleaned = cleaned.substring(0, lastOpen); - } - } - return cleaned.trim().isNotEmpty; + bool get _shouldShowTypingIndicator => + widget.isStreaming && _isAssistantResponseEmpty; + + bool get _isAssistantResponseEmpty { + final content = widget.message.content.trim(); + if (content.isNotEmpty) { + return false; } - for (final seg in _segments) { - if (seg.isTool && seg.toolCall != null) return true; - if (seg.isReasoning && seg.reasoning != null) return true; - final text = seg.text ?? ''; - if (textRenderable(text)) return true; + final hasFiles = widget.message.files?.isNotEmpty ?? false; + if (hasFiles) { + return false; } - return false; + + final hasAttachments = widget.message.attachmentIds?.isNotEmpty ?? false; + if (hasAttachments) { + return false; + } + + final hasVisibleStatus = widget.message.statusHistory + .where((status) => status.hidden != true) + .isNotEmpty; + if (hasVisibleStatus) { + return false; + } + + final hasFollowUps = widget.message.followUps.isNotEmpty; + if (hasFollowUps) { + return false; + } + + final hasCodeExecutions = widget.message.codeExecutions.isNotEmpty; + return !hasCodeExecutions; } void _buildCachedAvatar() { @@ -641,9 +641,7 @@ class _AssistantMessageWidgetState extends ConsumerState ); }, child: - (widget.isStreaming && - !_hasRenderableSegments && - _allowTypingIndicator) + (_allowTypingIndicator && _shouldShowTypingIndicator) ? KeyedSubtree( key: const ValueKey('typing'), child: _buildTypingIndicator(), @@ -971,126 +969,78 @@ class _AssistantMessageWidgetState extends ConsumerState } Widget _buildTypingIndicator() { - return Consumer( - builder: (context, ref, child) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Increase spacing between assistant name and typing indicator - const SizedBox(height: Spacing.md), - // Give the indicator breathing room to avoid any clip from transitions - Padding( - padding: const EdgeInsets.only(left: 4, bottom: 4), - child: SizedBox( - height: 22, - child: Platform.isIOS - ? _buildTypingPillBubble() - : _buildTypingEllipsis(), - ), - ), - ], - ); - }, - ); - } - - Widget _buildTypingEllipsis() { - final min = AnimationValues.typingIndicatorScale; - final dotColor = context.conduitTheme.textSecondary.withValues(alpha: 0.75); - - const double dotSize = 6.0; - const double gap = Spacing.xs; // 4.0 - final d = AnimationDelay.typingDelay; - final d2 = Duration(milliseconds: d.inMilliseconds * 2); - - Widget dot(Duration delay) { - return Container( - width: dotSize, - height: dotSize, - decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle), - ) - .animate(onPlay: (controller) => controller.repeat()) - .then(delay: delay) - .scale( - duration: AnimationDuration.typingIndicator, - curve: AnimationCurves.typingIndicator, - begin: Offset(min, min), - end: const Offset(1, 1), - ) - .then(delay: AnimationDelay.typingDelay) - .scale( - duration: AnimationDuration.typingIndicator, - curve: AnimationCurves.typingIndicator, - begin: const Offset(1, 1), - end: Offset(min, min), - ); - } - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - dot(Duration.zero), - const SizedBox(width: gap), - dot(d), - const SizedBox(width: gap), - dot(d2), + final theme = context.conduitTheme; + final gradient = LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + theme.surfaceBackground.withValues(alpha: 0.0), + theme.surfaceContainer.withValues(alpha: 0.9), ], ); - } + final haloColor = theme.textSecondary.withValues(alpha: 0.18); + final dotColor = theme.textSecondary.withValues(alpha: 0.75); - Widget _buildTypingPillBubble() { + const double dotSize = 8.0; final min = AnimationValues.typingIndicatorScale; - final bubbleColor = context.conduitTheme.surfaceContainerHighest; - final dotColor = context.conduitTheme.textSecondary.withValues(alpha: 0.75); + final dot = + Container( + width: dotSize, + height: dotSize, + decoration: BoxDecoration( + color: dotColor, + shape: BoxShape.circle, + ), + ) + .animate(onPlay: (controller) => controller.repeat()) + .scale( + duration: AnimationDuration.typingIndicator, + curve: AnimationCurves.typingIndicator, + begin: Offset(min, min), + end: const Offset(1, 1), + ) + .then(delay: AnimationDelay.typingDelay) + .scale( + duration: AnimationDuration.typingIndicator, + curve: AnimationCurves.typingIndicator, + begin: const Offset(1, 1), + end: Offset(min, min), + ); - const double dotSize = 6.0; - const double gap = Spacing.xs; // 4.0 - const double padV = 6.0; - const double padH = 10.0; - - final d = AnimationDelay.typingDelay; - final d2 = Duration(milliseconds: d.inMilliseconds * 2); - - Widget dot(Duration delay) { - return Container( - width: dotSize, - height: dotSize, - decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle), - ) - .animate(onPlay: (controller) => controller.repeat()) - .then(delay: delay) - .scale( - duration: AnimationDuration.typingIndicator, - curve: AnimationCurves.typingIndicator, - begin: Offset(min, min), - end: const Offset(1, 1), - ) - .then(delay: AnimationDelay.typingDelay) - .scale( - duration: AnimationDuration.typingIndicator, - curve: AnimationCurves.typingIndicator, - begin: const Offset(1, 1), - end: Offset(min, min), - ); - } - - return Container( - padding: const EdgeInsets.symmetric(horizontal: padH, vertical: padV), - decoration: BoxDecoration( - color: bubbleColor, - borderRadius: BorderRadius.circular(999), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - dot(Duration.zero), - const SizedBox(width: gap), - dot(d), - const SizedBox(width: gap), - dot(d2), - ], - ), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: Spacing.md), + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 4), + child: Container( + decoration: BoxDecoration( + gradient: gradient, + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: SizedBox( + height: dotSize * 2, + width: dotSize * 2, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: dotSize * 2, + height: dotSize * 2, + decoration: BoxDecoration( + color: haloColor, + shape: BoxShape.circle, + ), + ), + dot, + ], + ), + ), + ), + ), + ], ); }