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() {
// 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<AssistantMessageWidget>
);
}
bool get _hasRenderableSegments {
bool textRenderable(String t) {
String cleaned = t;
// Hide tool_calls blocks entirely
cleaned = cleaned.replaceAll(
RegExp(
r'<details\s+type="tool_calls"[^>]*>[\s\S]*?<\/details>',
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;
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<AssistantMessageWidget>
);
},
child:
(widget.isStreaming &&
!_hasRenderableSegments &&
_allowTypingIndicator)
(_allowTypingIndicator && _shouldShowTypingIndicator)
? KeyedSubtree(
key: const ValueKey('typing'),
child: _buildTypingIndicator(),
@@ -971,126 +969,78 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
}
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,
],
),
),
),
),
],
);
}