refactor: improve typing indicator logic in assistant message widget

- Simplified the logic for showing the typing indicator by introducing a new method `_shouldShowTypingIndicator`.
- Enhanced the `_isAssistantResponseEmpty` method to check for various conditions that determine if the assistant's response is empty.
- Refactored the `_buildTypingIndicator` method to streamline the UI rendering and improve visual feedback with a gradient background.
- Removed unnecessary comments and cleaned up the code for better readability and maintainability.
This commit is contained in:
cogwheel0
2025-09-30 23:56:35 +05:30
parent 37ebe46e15
commit fc4430e8df

View File

@@ -190,21 +190,27 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
} }
void _updateTypingIndicatorGate() { 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(); _typingGateTimer?.cancel();
final hasRenderable = _hasRenderableSegments; if (_shouldShowTypingIndicator) {
if (widget.isStreaming && !hasRenderable) { if (_allowTypingIndicator) {
_allowTypingIndicator = false; return;
}
_typingGateTimer = Timer(const Duration(milliseconds: 150), () { _typingGateTimer = Timer(const Duration(milliseconds: 150), () {
if (mounted) { if (!mounted || !_shouldShowTypingIndicator) {
setState(() { return;
_allowTypingIndicator = true;
});
} }
setState(() {
_allowTypingIndicator = true;
});
}); });
} else { } else if (_allowTypingIndicator) {
_allowTypingIndicator = false; if (mounted) {
setState(() {
_allowTypingIndicator = false;
});
} else {
_allowTypingIndicator = false;
}
} }
} }
@@ -467,45 +473,39 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
); );
} }
bool get _hasRenderableSegments { bool get _shouldShowTypingIndicator =>
bool textRenderable(String t) { widget.isStreaming && _isAssistantResponseEmpty;
String cleaned = t;
// Hide tool_calls blocks entirely bool get _isAssistantResponseEmpty {
cleaned = cleaned.replaceAll( final content = widget.message.content.trim();
RegExp( if (content.isNotEmpty) {
r'<details\s+type="tool_calls"[^>]*>[\s\S]*?<\/details>', return false;
multiLine: true,
dotAll: true,
),
'',
);
// Hide reasoning blocks as well in text check
cleaned = cleaned.replaceAll(
RegExp(
r'<details\s+type="reasoning"[^>]*>[\s\S]*?<\/details>',
multiLine: true,
dotAll: true,
),
'',
);
// If last <details> is unclosed, drop tail to avoid rendering raw tag
final lastOpen = cleaned.lastIndexOf('<details');
if (lastOpen >= 0) {
final tail = cleaned.substring(lastOpen);
if (!tail.contains('</details>')) {
cleaned = cleaned.substring(0, lastOpen);
}
}
return cleaned.trim().isNotEmpty;
} }
for (final seg in _segments) { final hasFiles = widget.message.files?.isNotEmpty ?? false;
if (seg.isTool && seg.toolCall != null) return true; if (hasFiles) {
if (seg.isReasoning && seg.reasoning != null) return true; return false;
final text = seg.text ?? '';
if (textRenderable(text)) return true;
} }
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() { void _buildCachedAvatar() {
@@ -641,9 +641,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
); );
}, },
child: child:
(widget.isStreaming && (_allowTypingIndicator && _shouldShowTypingIndicator)
!_hasRenderableSegments &&
_allowTypingIndicator)
? KeyedSubtree( ? KeyedSubtree(
key: const ValueKey('typing'), key: const ValueKey('typing'),
child: _buildTypingIndicator(), child: _buildTypingIndicator(),
@@ -971,126 +969,78 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
} }
Widget _buildTypingIndicator() { Widget _buildTypingIndicator() {
return Consumer( final theme = context.conduitTheme;
builder: (context, ref, child) { final gradient = LinearGradient(
return Column( begin: Alignment.bottomCenter,
crossAxisAlignment: CrossAxisAlignment.start, end: Alignment.topCenter,
children: [ colors: [
// Increase spacing between assistant name and typing indicator theme.surfaceBackground.withValues(alpha: 0.0),
const SizedBox(height: Spacing.md), theme.surfaceContainer.withValues(alpha: 0.9),
// 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 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 min = AnimationValues.typingIndicatorScale;
final bubbleColor = context.conduitTheme.surfaceContainerHighest; final dot =
final dotColor = context.conduitTheme.textSecondary.withValues(alpha: 0.75); 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; return Column(
const double gap = Spacing.xs; // 4.0 crossAxisAlignment: CrossAxisAlignment.start,
const double padV = 6.0; children: [
const double padH = 10.0; const SizedBox(height: Spacing.md),
Padding(
final d = AnimationDelay.typingDelay; padding: const EdgeInsets.only(left: 4, bottom: 4),
final d2 = Duration(milliseconds: d.inMilliseconds * 2); child: Container(
decoration: BoxDecoration(
Widget dot(Duration delay) { gradient: gradient,
return Container( borderRadius: BorderRadius.circular(16),
width: dotSize, ),
height: dotSize, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle), child: SizedBox(
) height: dotSize * 2,
.animate(onPlay: (controller) => controller.repeat()) width: dotSize * 2,
.then(delay: delay) child: Stack(
.scale( alignment: Alignment.center,
duration: AnimationDuration.typingIndicator, children: [
curve: AnimationCurves.typingIndicator, Container(
begin: Offset(min, min), width: dotSize * 2,
end: const Offset(1, 1), height: dotSize * 2,
) decoration: BoxDecoration(
.then(delay: AnimationDelay.typingDelay) color: haloColor,
.scale( shape: BoxShape.circle,
duration: AnimationDuration.typingIndicator, ),
curve: AnimationCurves.typingIndicator, ),
begin: const Offset(1, 1), dot,
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),
],
),
); );
} }