mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
feat(mobile): lock chat input while bot is responding + 20s timeout (#1538)
* feat(mobile): lock chat input while bot is responding + 20s timeout - Add _isWaitingForResponse flag to ChatProvider; set in _startPolling, cleared in _stopPolling so it covers the full polling lifecycle not just the initial HTTP POST - Add _pollingStartTime + 20s timeout in _pollForUpdates; if the bot never responds the flag resets, errorMessage is surfaced, and input unlocks automatically - Gate send button and keyboard shortcut on isSendingMessage || isWaitingForResponse so users cannot queue up multiple messages while a response is in flight (adding an interrupt like with other chat bots would require a larger rewrite of the backend structure) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(mobile): make polling timeout measure inactivity not total duration Reset _pollingStartTime whenever assistant content grows so the 20s timeout only fires if no new content has arrived in that window. Prevents cutting off a slow-but-streaming response mid-generation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(mobile): lock input for full polling duration, not just until first chunk - Add isPolling getter to ChatProvider (true while _pollingTimer is active) - Gate send button and intent on isPolling in addition to isWaitingForResponse so users cannot submit overlapping prompts while a response is still streaming - Also auto-scroll while polling is active * Fix chat polling timeout race and send re-entry guard Polling timeout was evaluated before the network attempt, allowing it to fire just as a response became ready. Timeout check now runs after each poll attempt and only when no progress was made; network errors fall through to the same check instead of silently swallowing the tick. Added _isSendInFlight boolean to prevent rapid taps from re-entering _sendMessage() during the async token fetch window before provider flags are set. Guard is set synchronously at the top of the method and cleared in a finally block. * fix(mobile): prevent overlapping polls and empty-placeholder stop Add _isPollingRequestInFlight guard so Timer.periodic ticks are skipped if a getChat request is still in flight, preventing stale results from resetting state out of order. Fix empty assistant placeholder incorrectly triggering _stopPolling: stable is only declared when a previously observed length exists and hasn't grown. An initial empty message keeps polling until content arrives or the timeout fires. * fix(mobile): reset _lastAssistantContentLength in _stopPolling Prevents stale content-length state from a prior polling session bleeding into the next one. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,10 @@ class ChatProvider with ChangeNotifier {
|
|||||||
bool _isWaitingForResponse = false;
|
bool _isWaitingForResponse = false;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
Timer? _pollingTimer;
|
Timer? _pollingTimer;
|
||||||
|
DateTime? _pollingStartTime;
|
||||||
|
bool _isPollingRequestInFlight = false;
|
||||||
|
|
||||||
|
static const _pollingTimeout = Duration(seconds: 20);
|
||||||
|
|
||||||
/// Content length of the last assistant message from the previous poll.
|
/// Content length of the last assistant message from the previous poll.
|
||||||
/// Used to detect when the LLM has finished writing (no growth between polls).
|
/// 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 isLoading => _isLoading;
|
||||||
bool get isSendingMessage => _isSendingMessage;
|
bool get isSendingMessage => _isSendingMessage;
|
||||||
bool get isWaitingForResponse => _isWaitingForResponse;
|
bool get isWaitingForResponse => _isWaitingForResponse;
|
||||||
|
bool get isPolling => _pollingTimer != null;
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
|
|
||||||
/// Fetch list of chats
|
/// Fetch list of chats
|
||||||
@@ -262,10 +267,17 @@ class ChatProvider with ChangeNotifier {
|
|||||||
_pollingTimer?.cancel();
|
_pollingTimer?.cancel();
|
||||||
_lastAssistantContentLength = null;
|
_lastAssistantContentLength = null;
|
||||||
_isWaitingForResponse = true;
|
_isWaitingForResponse = true;
|
||||||
|
_pollingStartTime = DateTime.now();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
_pollingTimer = Timer.periodic(const Duration(seconds: 2), (timer) async {
|
_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() {
|
void _stopPolling() {
|
||||||
_pollingTimer?.cancel();
|
_pollingTimer?.cancel();
|
||||||
_pollingTimer = null;
|
_pollingTimer = null;
|
||||||
|
_pollingStartTime = null;
|
||||||
|
_isPollingRequestInFlight = false;
|
||||||
_isWaitingForResponse = false;
|
_isWaitingForResponse = false;
|
||||||
|
_lastAssistantContentLength = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Poll for updates
|
/// Poll for updates
|
||||||
@@ -333,19 +348,39 @@ class ChatProvider with ChangeNotifier {
|
|||||||
final lastMessage = updatedChat.messages.lastOrNull;
|
final lastMessage = updatedChat.messages.lastOrNull;
|
||||||
if (lastMessage != null && lastMessage.isAssistant) {
|
if (lastMessage != null && lastMessage.isAssistant) {
|
||||||
final newLen = lastMessage.content.length;
|
final newLen = lastMessage.content.length;
|
||||||
if (newLen > (_lastAssistantContentLength ?? 0)) {
|
final previousLen = _lastAssistantContentLength;
|
||||||
|
|
||||||
|
if (newLen > (previousLen ?? -1)) {
|
||||||
_lastAssistantContentLength = newLen;
|
_lastAssistantContentLength = newLen;
|
||||||
} else {
|
if (newLen > 0) {
|
||||||
// Content stable: no growth since last poll — done.
|
// 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();
|
_stopPolling();
|
||||||
_lastAssistantContentLength = null;
|
_lastAssistantContentLength = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// newLen == 0 with previousLen already 0: still empty, keep polling
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// Network error — allow polling to continue; timeout check below will
|
||||||
|
// stop it if the deadline has passed.
|
||||||
debugPrint('Polling error: ${e.toString()}');
|
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
|
/// Clear current chat
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class _ChatConversationScreenState extends State<ChatConversationScreen> {
|
|||||||
|
|
||||||
ChatProvider? _chatProvider;
|
ChatProvider? _chatProvider;
|
||||||
bool _listenerAdded = false;
|
bool _listenerAdded = false;
|
||||||
|
bool _isSendInFlight = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -68,7 +69,7 @@ class _ChatConversationScreenState extends State<ChatConversationScreen> {
|
|||||||
void _onChatChanged() {
|
void _onChatChanged() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final chatProvider = Provider.of<ChatProvider>(context, listen: false);
|
final chatProvider = Provider.of<ChatProvider>(context, listen: false);
|
||||||
if (chatProvider.isWaitingForResponse || chatProvider.isSendingMessage) {
|
if (chatProvider.isWaitingForResponse || chatProvider.isSendingMessage || chatProvider.isPolling) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) _scrollToBottom();
|
if (mounted) _scrollToBottom();
|
||||||
});
|
});
|
||||||
@@ -120,9 +121,12 @@ class _ChatConversationScreenState extends State<ChatConversationScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _sendMessage() async {
|
Future<void> _sendMessage() async {
|
||||||
|
if (_isSendInFlight) return;
|
||||||
final content = _messageController.text.trim();
|
final content = _messageController.text.trim();
|
||||||
if (content.isEmpty) return;
|
if (content.isEmpty) return;
|
||||||
|
setState(() => _isSendInFlight = true);
|
||||||
|
|
||||||
|
try {
|
||||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||||
final chatProvider = Provider.of<ChatProvider>(context, listen: false);
|
final chatProvider = Provider.of<ChatProvider>(context, listen: false);
|
||||||
|
|
||||||
@@ -182,6 +186,9 @@ class _ChatConversationScreenState extends State<ChatConversationScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isSendInFlight = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _editTitle() async {
|
Future<void> _editTitle() async {
|
||||||
@@ -359,7 +366,7 @@ class _ChatConversationScreenState extends State<ChatConversationScreen> {
|
|||||||
actions: <Type, Action<Intent>>{
|
actions: <Type, Action<Intent>>{
|
||||||
_SendMessageIntent: CallbackAction<_SendMessageIntent>(
|
_SendMessageIntent: CallbackAction<_SendMessageIntent>(
|
||||||
onInvoke: (_) {
|
onInvoke: (_) {
|
||||||
if (!chatProvider.isSendingMessage) _sendMessage();
|
if (!_isSendInFlight && !chatProvider.isSendingMessage && !chatProvider.isWaitingForResponse && !chatProvider.isPolling) _sendMessage();
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -387,7 +394,7 @@ class _ChatConversationScreenState extends State<ChatConversationScreen> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.send),
|
icon: const Icon(Icons.send),
|
||||||
onPressed: chatProvider.isSendingMessage
|
onPressed: (_isSendInFlight || chatProvider.isSendingMessage || chatProvider.isWaitingForResponse || chatProvider.isPolling)
|
||||||
? null
|
? null
|
||||||
: _sendMessage,
|
: _sendMessage,
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
|
|||||||
Reference in New Issue
Block a user