Fix chat response never appearing after send

Root cause: when SSE delivered any chunk, maxAttempts was set to 1 with
no delay. That single immediate poll hit the server before it finished
writing to the DB, received status='generating' with empty content, then
overwrote the SSE-streamed text with the stale empty row and exited.
isStreaming went false and the UI was stuck showing '...'.

Two fixes:
1. Always poll up to 20 times regardless of SSE success. SSE closing
   does not mean the server has committed the final row to the DB.
2. When SSE delivered content, only replace state with polled data if
   the polled assistant message is complete or has content — this
   prevents clobbering streamed text with a stale empty DB row while
   the server is still writing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 12:05:54 -05:00
parent eef101bef3
commit 98ad9a2557
+22 -8
View File
@@ -100,24 +100,38 @@ class MessagesNotifier extends FamilyAsyncNotifier<List<Message>, int> {
// ── Step 3: Poll the API until we have a completed assistant response.
//
// If SSE delivered content the server is done; one reload suffices.
// If SSE delivered nothing, the LLM may still be generating — we retry
// with short delays so the response appears as soon as it's ready.
// A completed message is one whose status is not 'generating'.
// We always retry up to 20 times regardless of whether SSE streamed
// content. SSE ending does NOT mean the server has finished writing to
// the DB — there can be a brief gap between the stream closing and the
// row being marked complete.
//
// To avoid clobbering SSE-streamed text with a stale empty DB row, we
// only replace state when the polled data is complete OR has content.
try {
final maxAttempts = streamedContent ? 1 : 20;
for (var attempt = 0; attempt < maxAttempts; attempt++) {
for (var attempt = 0; attempt < 20; attempt++) {
if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
final (conv, fresh) = await repo.getMessages(convId);
state = AsyncData(fresh);
ref.read(conversationsProvider.notifier).patchConversation(conv);
final done = fresh.any(
(m) => m.role == MessageRole.assistant && m.status != 'generating',
);
final polledHasContent = fresh.any(
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
);
// Update UI state when:
// • SSE never delivered anything (polling is the only data source), or
// • the polled response is complete or has content to show.
if (!streamedContent || done || polledHasContent) {
state = AsyncData(fresh);
}
if (done) break;
}
} catch (_) {
// If every reload fails, at least clear the generating placeholder.
// Polling failed entirely — clear the generating placeholder so the UI
// doesn't spin forever.
final msgs = state.valueOrNull;
if (msgs != null && msgs.isNotEmpty && msgs.last.status == 'generating') {
state = AsyncData([