diff --git a/mobile/lib/providers/chat_provider.dart b/mobile/lib/providers/chat_provider.dart index 158e2a147..e0c467f9b 100644 --- a/mobile/lib/providers/chat_provider.dart +++ b/mobile/lib/providers/chat_provider.dart @@ -14,6 +14,10 @@ class ChatProvider with ChangeNotifier { bool _isWaitingForResponse = false; String? _errorMessage; Timer? _pollingTimer; + DateTime? _pollingStartTime; + bool _isPollingRequestInFlight = false; + + static const _pollingTimeout = Duration(seconds: 20); /// Content length of the last assistant message from the previous poll. /// Used to detect when the LLM has finished writing (no growth between polls). @@ -24,6 +28,7 @@ class ChatProvider with ChangeNotifier { bool get isLoading => _isLoading; bool get isSendingMessage => _isSendingMessage; bool get isWaitingForResponse => _isWaitingForResponse; + bool get isPolling => _pollingTimer != null; String? get errorMessage => _errorMessage; /// Fetch list of chats @@ -262,10 +267,17 @@ class ChatProvider with ChangeNotifier { _pollingTimer?.cancel(); _lastAssistantContentLength = null; _isWaitingForResponse = true; + _pollingStartTime = DateTime.now(); notifyListeners(); _pollingTimer = Timer.periodic(const Duration(seconds: 2), (timer) async { - await _pollForUpdates(accessToken, chatId); + if (_isPollingRequestInFlight) return; + _isPollingRequestInFlight = true; + try { + await _pollForUpdates(accessToken, chatId); + } finally { + _isPollingRequestInFlight = false; + } }); } @@ -273,7 +285,10 @@ class ChatProvider with ChangeNotifier { void _stopPolling() { _pollingTimer?.cancel(); _pollingTimer = null; + _pollingStartTime = null; + _isPollingRequestInFlight = false; _isWaitingForResponse = false; + _lastAssistantContentLength = null; } /// Poll for updates @@ -333,19 +348,39 @@ class ChatProvider with ChangeNotifier { final lastMessage = updatedChat.messages.lastOrNull; if (lastMessage != null && lastMessage.isAssistant) { final newLen = lastMessage.content.length; - if (newLen > (_lastAssistantContentLength ?? 0)) { + final previousLen = _lastAssistantContentLength; + + if (newLen > (previousLen ?? -1)) { _lastAssistantContentLength = newLen; - } else { - // Content stable: no growth since last poll — done. + if (newLen > 0) { + // Content is growing — reset the inactivity clock. + _pollingStartTime = DateTime.now(); + return; // progress made, don't evaluate timeout this tick + } + // newLen == 0: empty placeholder, keep polling + } else if (newLen > 0) { + // Content stable and non-empty: no growth since last poll — done. _stopPolling(); _lastAssistantContentLength = null; notifyListeners(); + return; } + // newLen == 0 with previousLen already 0: still empty, keep polling } } } catch (e) { + // Network error — allow polling to continue; timeout check below will + // stop it if the deadline has passed. debugPrint('Polling error: ${e.toString()}'); } + + // Evaluate timeout only after the attempt, and only when no progress was made. + if (_pollingStartTime != null && + DateTime.now().difference(_pollingStartTime!) >= _pollingTimeout) { + _stopPolling(); + _errorMessage = 'The assistant took too long to respond. Please try again.'; + notifyListeners(); + } } /// Clear current chat diff --git a/mobile/lib/screens/chat_conversation_screen.dart b/mobile/lib/screens/chat_conversation_screen.dart index 258748df9..9f9b3c73c 100644 --- a/mobile/lib/screens/chat_conversation_screen.dart +++ b/mobile/lib/screens/chat_conversation_screen.dart @@ -34,6 +34,7 @@ class _ChatConversationScreenState extends State { ChatProvider? _chatProvider; bool _listenerAdded = false; + bool _isSendInFlight = false; @override void initState() { @@ -68,7 +69,7 @@ class _ChatConversationScreenState extends State { void _onChatChanged() { if (!mounted) return; final chatProvider = Provider.of(context, listen: false); - if (chatProvider.isWaitingForResponse || chatProvider.isSendingMessage) { + if (chatProvider.isWaitingForResponse || chatProvider.isSendingMessage || chatProvider.isPolling) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _scrollToBottom(); }); @@ -120,9 +121,12 @@ class _ChatConversationScreenState extends State { } Future _sendMessage() async { + if (_isSendInFlight) return; final content = _messageController.text.trim(); if (content.isEmpty) return; + setState(() => _isSendInFlight = true); + try { final authProvider = Provider.of(context, listen: false); final chatProvider = Provider.of(context, listen: false); @@ -182,6 +186,9 @@ class _ChatConversationScreenState extends State { ); } }); + } finally { + if (mounted) setState(() => _isSendInFlight = false); + } } Future _editTitle() async { @@ -359,7 +366,7 @@ class _ChatConversationScreenState extends State { actions: >{ _SendMessageIntent: CallbackAction<_SendMessageIntent>( onInvoke: (_) { - if (!chatProvider.isSendingMessage) _sendMessage(); + if (!_isSendInFlight && !chatProvider.isSendingMessage && !chatProvider.isWaitingForResponse && !chatProvider.isPolling) _sendMessage(); return null; }, ), @@ -387,7 +394,7 @@ class _ChatConversationScreenState extends State { const SizedBox(width: 8), IconButton( icon: const Icon(Icons.send), - onPressed: chatProvider.isSendingMessage + onPressed: (_isSendInFlight || chatProvider.isSendingMessage || chatProvider.isWaitingForResponse || chatProvider.isPolling) ? null : _sendMessage, color: colorScheme.primary,