From 98ad9a255753e1feabb821d919012ff91cc0911d Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 1 Mar 2026 12:05:54 -0500 Subject: [PATCH] Fix chat response never appearing after send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/providers/chat_provider.dart | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/lib/providers/chat_provider.dart b/lib/providers/chat_provider.dart index 4b4e687..50fa882 100644 --- a/lib/providers/chat_provider.dart +++ b/lib/providers/chat_provider.dart @@ -100,24 +100,38 @@ class MessagesNotifier extends FamilyAsyncNotifier, 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([