From 75b7d6d0fe991062fec8830f692846824cb705a3 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 13 Apr 2026 18:13:46 -0400 Subject: [PATCH] feat(chat): stall watchdog, pull-to-refresh, and unfreeze on refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile chat occasionally froze with the input disabled and nothing progressing — the underlying cause is SSE sockets dropping silently on mobile network handoffs / proxy idle timeouts. Dio never observes the close, so the send loop's `await for` hangs indefinitely with isStreaming=true. Three changes: - Wrap the SSE stream in a StreamIterator with a 45s per-event timeout as a stall watchdog. On timeout, break out and let the polling pass reconcile state from the server. - Add pull-to-refresh on the chat message list and an AppBar refresh button with inline spinner feedback. Works on both empty and populated states. - MessagesNotifier.refresh() now also clears isStreaming / streamingStatus when the server reports the latest assistant message as complete — so a stuck UI unfreezes the moment the user pulls down or taps refresh, even if the original SSE loop is still hanging. Co-Authored-By: Claude Opus 4.6 --- lib/providers/chat_provider.dart | 36 ++++++++++++++- lib/screens/chat/chat_screen.dart | 74 ++++++++++++++++++++++++------- 2 files changed, 93 insertions(+), 17 deletions(-) diff --git a/lib/providers/chat_provider.dart b/lib/providers/chat_provider.dart index 1ac26cb..bca71c1 100644 --- a/lib/providers/chat_provider.dart +++ b/lib/providers/chat_provider.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/models/conversation.dart'; @@ -93,10 +95,27 @@ class MessagesNotifier extends AsyncNotifier> { } /// Re-fetch messages without clearing the current list (no flicker). + /// + /// Also unfreezes the UI if streaming state got stuck true — this happens + /// when an SSE connection dies silently (mobile network handoff, app + /// backgrounded mid-stream, reverse proxy dropping idle sockets) and the + /// send loop never observes a close. If the server-side message is already + /// done, we clear `isStreamingProvider` so the input unlocks. Future refresh() async { final (_, messages) = await ref.read(chatRepositoryProvider).getMessages(_convId); state = AsyncData(messages); + Message? lastAssistant; + for (var i = messages.length - 1; i >= 0; i--) { + if (messages[i].role == MessageRole.assistant) { + lastAssistant = messages[i]; + break; + } + } + if (lastAssistant != null && lastAssistant.status != 'generating') { + ref.read(isStreamingProvider(_convId).notifier).state = false; + ref.read(streamingStatusProvider(_convId).notifier).state = ''; + } } Future sendMessage(String content) async { @@ -129,9 +148,19 @@ class MessagesNotifier extends AsyncNotifier> { } // ── Step 2: Stream the response (best effort — silent on failure). ── + // + // We use a StreamIterator with a per-event timeout as a stall watchdog. + // Mobile networks occasionally drop SSE sockets silently: the TCP + // connection is half-closed, Dio never sees the close, and `await for` + // hangs forever with `isStreaming=true`, freezing the input. If no + // event arrives within the watchdog window we bail out and let the + // polling pass below reconcile state from the server. + const stallTimeout = Duration(seconds: 45); bool streamedContent = false; + final iter = StreamIterator(repo.streamGeneration(convId)); try { - await for (final event in repo.streamGeneration(convId)) { + while (await iter.moveNext().timeout(stallTimeout)) { + final event = iter.current; if (event is ChatTextChunk) { streamedContent = true; ref.read(streamingStatusProvider(convId).notifier).state = ''; @@ -157,8 +186,13 @@ class MessagesNotifier extends AsyncNotifier> { state = AsyncData([...msgs.sublist(0, msgs.length - 1), updated]); } } + } on TimeoutException { + // Stall watchdog — no SSE event for stallTimeout. Fall through to + // polling so the UI eventually unfreezes even if the socket is dead. } catch (_) { // SSE failed — fall through to the polling reload below. + } finally { + await iter.cancel(); } // ── Step 3: Poll the API until we have a completed assistant response. diff --git a/lib/screens/chat/chat_screen.dart b/lib/screens/chat/chat_screen.dart index 27698b5..5cc3811 100644 --- a/lib/screens/chat/chat_screen.dart +++ b/lib/screens/chat/chat_screen.dart @@ -20,6 +20,25 @@ class _ChatScreenState extends ConsumerState with WidgetsBindingObserver { final _controller = TextEditingController(); final _scrollController = ScrollController(); + bool _refreshing = false; + + Future _refreshMessages() async { + if (_refreshing) return; + setState(() => _refreshing = true); + try { + await ref + .read(messagesProvider(widget.conversationId).notifier) + .refresh(); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not refresh messages.')), + ); + } + } finally { + if (mounted) setState(() => _refreshing = false); + } + } @override void initState() { @@ -136,6 +155,19 @@ class _ChatScreenState extends ConsumerState return Scaffold( appBar: AppBar( title: Text(convTitle?.isNotEmpty == true ? convTitle! : 'Chat'), + actions: [ + IconButton( + tooltip: 'Refresh', + onPressed: _refreshing ? null : _refreshMessages, + icon: _refreshing + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + ), + ], ), body: Column( children: [ @@ -147,22 +179,32 @@ class _ChatScreenState extends ConsumerState child: Text('Could not load messages.'), ), data: (messages) { - if (messages.isEmpty) { - return const Center( - child: Text('Send a message to start.')); - } - return ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 12), - itemCount: messages.length, - itemBuilder: (context, i) => ChatMessageBubble( - message: messages[i], - streamingStatus: (i == messages.length - 1 && - messages[i].status == 'generating') - ? streamingStatus - : '', - ), + return RefreshIndicator( + onRefresh: _refreshMessages, + child: messages.isEmpty + ? ListView( + // Needs to be scrollable for RefreshIndicator to + // fire on empty state — plain Center won't work. + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + SizedBox(height: 240), + Center(child: Text('Send a message to start.')), + ], + ) + : ListView.builder( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 12), + itemCount: messages.length, + itemBuilder: (context, i) => ChatMessageBubble( + message: messages[i], + streamingStatus: (i == messages.length - 1 && + messages[i].status == 'generating') + ? streamingStatus + : '', + ), + ), ); }, ),