fix: stabilize chat run lifecycle rendering
This commit is contained in:
@@ -44,6 +44,7 @@ const _attachmentPreviewGap = 8.0;
|
||||
const _bottomStackReservedHeight = 116.0;
|
||||
const _toolResultWidthFactor = 0.9;
|
||||
const _pullRefreshMinVisibleMs = 450;
|
||||
const _waitingIndicatorReservedHeight = 42.0;
|
||||
|
||||
const homeConversationStageKey = ValueKey('home_conversation_stage');
|
||||
const homeBottomInputStackKey = ValueKey('home_bottom_input_stack');
|
||||
@@ -64,7 +65,6 @@ const _recordingActiveLabelColor = AppColors.white;
|
||||
class HomeScreen extends StatefulWidget {
|
||||
final VoiceRecorder? voiceRecorder;
|
||||
final Future<String> Function(String filePath)? onTranscribeAudio;
|
||||
final Future<void> Function(String transcript)? onAutoSendTranscript;
|
||||
final ChatBloc? chatBloc;
|
||||
final bool autoLoadHistory;
|
||||
final List<XFile> initialSelectedImages;
|
||||
@@ -73,7 +73,6 @@ class HomeScreen extends StatefulWidget {
|
||||
super.key,
|
||||
this.voiceRecorder,
|
||||
this.onTranscribeAudio,
|
||||
this.onAutoSendTranscript,
|
||||
this.chatBloc,
|
||||
this.autoLoadHistory = true,
|
||||
this.initialSelectedImages = const [],
|
||||
@@ -91,7 +90,6 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
late final VoiceRecorder _voiceRecorder;
|
||||
late final InboxApi _inboxApi;
|
||||
late final Future<String> Function(String filePath) _transcribeAudio;
|
||||
late final Future<void> Function(String transcript) _autoSendTranscript;
|
||||
late final AnimationController _listeningAnimationController;
|
||||
bool _isRecording = false;
|
||||
bool _isHoldToSpeakMode = true;
|
||||
@@ -101,6 +99,8 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
bool _isPullRefreshing = false;
|
||||
int _unreadCount = 0;
|
||||
final List<XFile> _selectedImages = [];
|
||||
int _lastObservedItemCount = 0;
|
||||
bool _lastObservedWaiting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -110,7 +110,6 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
_inboxApi = sl<InboxApi>();
|
||||
_transcribeAudio =
|
||||
widget.onTranscribeAudio ?? _chatBloc.transcribeAudioFile;
|
||||
_autoSendTranscript = widget.onAutoSendTranscript ?? _chatBloc.sendMessage;
|
||||
_listeningAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: _rippleDurationMs),
|
||||
@@ -119,6 +118,8 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
if (widget.autoLoadHistory) {
|
||||
_chatBloc.loadHistory();
|
||||
}
|
||||
_lastObservedItemCount = _chatBloc.state.items.length;
|
||||
_lastObservedWaiting = _isAgentWaiting(_chatBloc.state);
|
||||
_loadUnreadCount();
|
||||
}
|
||||
|
||||
@@ -154,6 +155,15 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
if (state.error != null) {
|
||||
Toast.show(context, state.error!, type: ToastType.error);
|
||||
}
|
||||
final isWaitingNow = _isAgentWaiting(state);
|
||||
final hasItemCountChanged =
|
||||
state.items.length != _lastObservedItemCount;
|
||||
final waitingStateChanged = isWaitingNow != _lastObservedWaiting;
|
||||
if (hasItemCountChanged || waitingStateChanged) {
|
||||
_scheduleAutoScroll(animated: hasItemCountChanged);
|
||||
}
|
||||
_lastObservedItemCount = state.items.length;
|
||||
_lastObservedWaiting = isWaitingNow;
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Scaffold(
|
||||
@@ -190,8 +200,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
}
|
||||
|
||||
Widget _buildChatArea(BuildContext context, ChatState state) {
|
||||
final showWaitingIndicator =
|
||||
state.isWaitingFirstToken || state.isStreaming || state.isCancelling;
|
||||
final showWaitingIndicator = _isAgentWaiting(state);
|
||||
|
||||
if (state.isLoadingHistory && state.items.isEmpty) {
|
||||
return const Center(
|
||||
@@ -219,7 +228,12 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.only(top: AppSpacing.sm),
|
||||
padding: EdgeInsets.only(
|
||||
top: AppSpacing.sm,
|
||||
bottom: showWaitingIndicator
|
||||
? _waitingIndicatorReservedHeight
|
||||
: AppSpacing.none,
|
||||
),
|
||||
itemCount:
|
||||
state.items.length + (state.hasEarlierHistory ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
@@ -391,6 +405,28 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
context.read<ChatBloc>().loadMoreHistory();
|
||||
}
|
||||
|
||||
bool _isAgentWaiting(ChatState state) {
|
||||
return state.isWaitingFirstToken || state.isStreaming || state.isCancelling;
|
||||
}
|
||||
|
||||
void _scheduleAutoScroll({required bool animated}) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!_scrollController.hasClients) {
|
||||
return;
|
||||
}
|
||||
final maxExtent = _scrollController.position.maxScrollExtent;
|
||||
if (animated) {
|
||||
_scrollController.animateTo(
|
||||
maxExtent,
|
||||
duration: const Duration(milliseconds: _scrollDurationMs),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
return;
|
||||
}
|
||||
_scrollController.jumpTo(maxExtent);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildChatItem(ChatListItem item) {
|
||||
switch (item.type) {
|
||||
case ChatItemType.message:
|
||||
@@ -482,47 +518,89 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
}
|
||||
|
||||
bool _isRenderableImageAttachment(Map<String, dynamic> attachment) {
|
||||
final path = attachment['path'];
|
||||
final url = attachment['url'];
|
||||
final mimeType = attachment['mimeType'];
|
||||
return url is String &&
|
||||
url.isNotEmpty &&
|
||||
final hasRenderableSource =
|
||||
(url is String && url.isNotEmpty) ||
|
||||
(path is String && path.isNotEmpty);
|
||||
return hasRenderableSource &&
|
||||
mimeType is String &&
|
||||
mimeType.startsWith('image/');
|
||||
}
|
||||
|
||||
Widget _buildHistoryAttachmentTile(Map<String, dynamic> attachment) {
|
||||
final path = attachment['path'];
|
||||
final url = attachment['url'];
|
||||
if (url is! String || url.isEmpty) {
|
||||
final isUploading = attachment['uploading'] == true;
|
||||
|
||||
final Widget image;
|
||||
if (url is String && url.isNotEmpty) {
|
||||
image = Image.network(
|
||||
url,
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return const Center(
|
||||
child: AppLoadingIndicator(
|
||||
variant: AppLoadingVariant.inline,
|
||||
size: _transcribingSpinnerSize,
|
||||
strokeWidth: _transcribingStrokeWidth,
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Center(
|
||||
child: Icon(
|
||||
LucideIcons.imageOff,
|
||||
size: _iconSize,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else if (path is String && path.isNotEmpty) {
|
||||
image = Image.file(
|
||||
File(path),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Center(
|
||||
child: Icon(
|
||||
LucideIcons.imageOff,
|
||||
size: _iconSize,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(_attachmentPreviewRadius),
|
||||
child: Container(
|
||||
width: _attachmentPreviewSize,
|
||||
height: _attachmentPreviewSize,
|
||||
color: AppColors.slate100,
|
||||
child: Image.network(
|
||||
url,
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return const Center(
|
||||
child: AppLoadingIndicator(
|
||||
variant: AppLoadingVariant.inline,
|
||||
size: _transcribingSpinnerSize,
|
||||
strokeWidth: _transcribingStrokeWidth,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
image,
|
||||
if (isUploading)
|
||||
ColoredBox(
|
||||
color: AppColors.slate900.withValues(alpha: 0.2),
|
||||
child: const Center(
|
||||
child: AppLoadingIndicator(
|
||||
variant: AppLoadingVariant.inline,
|
||||
size: _transcribingSpinnerSize,
|
||||
strokeWidth: _transcribingStrokeWidth,
|
||||
color: AppColors.white,
|
||||
trackColor: AppColors.slate200,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Center(
|
||||
child: Icon(
|
||||
LucideIcons.imageOff,
|
||||
size: _iconSize,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
);
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -841,7 +919,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
});
|
||||
|
||||
try {
|
||||
await context.read<ChatBloc>().sendMessage(content, images: images);
|
||||
await _chatBloc.sendMessage(content, images: images);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -1034,8 +1112,11 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
TextPosition(offset: transcript.length),
|
||||
);
|
||||
if (autoSendAfterTranscribe) {
|
||||
_messageController.clear();
|
||||
await _autoSendTranscript(normalizedTranscript);
|
||||
_messageController.text = normalizedTranscript;
|
||||
_messageController.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: normalizedTranscript.length),
|
||||
);
|
||||
await _sendMessage(context);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
|
||||
Reference in New Issue
Block a user