feat(chat): stall watchdog, pull-to-refresh, and unfreeze on refresh
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../data/models/conversation.dart';
|
import '../data/models/conversation.dart';
|
||||||
@@ -93,10 +95,27 @@ class MessagesNotifier extends AsyncNotifier<List<Message>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Re-fetch messages without clearing the current list (no flicker).
|
/// 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<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
final (_, messages) =
|
final (_, messages) =
|
||||||
await ref.read(chatRepositoryProvider).getMessages(_convId);
|
await ref.read(chatRepositoryProvider).getMessages(_convId);
|
||||||
state = AsyncData(messages);
|
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<void> sendMessage(String content) async {
|
Future<void> sendMessage(String content) async {
|
||||||
@@ -129,9 +148,19 @@ class MessagesNotifier extends AsyncNotifier<List<Message>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 2: Stream the response (best effort — silent on failure). ──
|
// ── 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;
|
bool streamedContent = false;
|
||||||
|
final iter = StreamIterator(repo.streamGeneration(convId));
|
||||||
try {
|
try {
|
||||||
await for (final event in repo.streamGeneration(convId)) {
|
while (await iter.moveNext().timeout(stallTimeout)) {
|
||||||
|
final event = iter.current;
|
||||||
if (event is ChatTextChunk) {
|
if (event is ChatTextChunk) {
|
||||||
streamedContent = true;
|
streamedContent = true;
|
||||||
ref.read(streamingStatusProvider(convId).notifier).state = '';
|
ref.read(streamingStatusProvider(convId).notifier).state = '';
|
||||||
@@ -157,8 +186,13 @@ class MessagesNotifier extends AsyncNotifier<List<Message>> {
|
|||||||
state = AsyncData([...msgs.sublist(0, msgs.length - 1), updated]);
|
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 (_) {
|
} catch (_) {
|
||||||
// SSE failed — fall through to the polling reload below.
|
// 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.
|
// ── Step 3: Poll the API until we have a completed assistant response.
|
||||||
|
|||||||
@@ -20,6 +20,25 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
|
|||||||
with WidgetsBindingObserver {
|
with WidgetsBindingObserver {
|
||||||
final _controller = TextEditingController();
|
final _controller = TextEditingController();
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
|
bool _refreshing = false;
|
||||||
|
|
||||||
|
Future<void> _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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -136,6 +155,19 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(convTitle?.isNotEmpty == true ? convTitle! : 'Chat'),
|
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(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -147,22 +179,32 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
|
|||||||
child: Text('Could not load messages.'),
|
child: Text('Could not load messages.'),
|
||||||
),
|
),
|
||||||
data: (messages) {
|
data: (messages) {
|
||||||
if (messages.isEmpty) {
|
return RefreshIndicator(
|
||||||
return const Center(
|
onRefresh: _refreshMessages,
|
||||||
child: Text('Send a message to start.'));
|
child: messages.isEmpty
|
||||||
}
|
? ListView(
|
||||||
return ListView.builder(
|
// Needs to be scrollable for RefreshIndicator to
|
||||||
controller: _scrollController,
|
// fire on empty state — plain Center won't work.
|
||||||
padding: const EdgeInsets.symmetric(
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
horizontal: 8, vertical: 12),
|
children: const [
|
||||||
itemCount: messages.length,
|
SizedBox(height: 240),
|
||||||
itemBuilder: (context, i) => ChatMessageBubble(
|
Center(child: Text('Send a message to start.')),
|
||||||
message: messages[i],
|
],
|
||||||
streamingStatus: (i == messages.length - 1 &&
|
)
|
||||||
messages[i].status == 'generating')
|
: ListView.builder(
|
||||||
? streamingStatus
|
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
|
||||||
|
: '',
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user