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