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:
Tristan Katana
2026-04-23 22:48:28 +03:00
committed by GitHub
parent 6f195c6c9c
commit 3e36fae751
2 changed files with 49 additions and 7 deletions

View File

@@ -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

View File

@@ -34,6 +34,7 @@ class _ChatConversationScreenState extends State<ChatConversationScreen> {
ChatProvider? _chatProvider;
bool _listenerAdded = false;
bool _isSendInFlight = false;
@override
void initState() {
@@ -68,7 +69,7 @@ class _ChatConversationScreenState extends State<ChatConversationScreen> {
void _onChatChanged() {
if (!mounted) return;
final chatProvider = Provider.of<ChatProvider>(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<ChatConversationScreen> {
}
Future<void> _sendMessage() async {
if (_isSendInFlight) return;
final content = _messageController.text.trim();
if (content.isEmpty) return;
setState(() => _isSendInFlight = true);
try {
final authProvider = Provider.of<AuthProvider>(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 {
@@ -359,7 +366,7 @@ class _ChatConversationScreenState extends State<ChatConversationScreen> {
actions: <Type, Action<Intent>>{
_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<ChatConversationScreen> {
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,