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:
@@ -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,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user