feat(briefing): stall watchdog + pull-to-refresh; chat attaches to in-flight streams
Briefing chat now uses the same StreamIterator + 45s per-event timeout as the main chat provider, so a dropped SSE socket no longer leaves isBriefingStreaming stuck true. Adds refreshMessages() that unfreezes state when the server-side message is complete, wired to a new pull-to-refresh gesture and the app lifecycle-resume hook. Chat provider gains attachToGeneration() — safe to call unconditionally on screen init, so landing on a conversation that's already mid-stream (e.g. the /news discuss button, which now creates a conv and auto-kicks generation server-side) picks up live tokens instead of freezing on the placeholder. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -118,6 +118,120 @@ class MessagesNotifier extends AsyncNotifier<List<Message>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach to an already-running generation for this conversation.
|
||||
///
|
||||
/// Used when the chat screen lands on a conversation that was started by
|
||||
/// something other than a direct user message — e.g. the /news discuss
|
||||
/// button, which creates a conversation on the backend and auto-kicks a
|
||||
/// generation before navigating. Without this the stream runs to
|
||||
/// completion invisibly and the screen only shows the final persisted
|
||||
/// message after a manual refresh.
|
||||
///
|
||||
/// Safe to call unconditionally on screen init: no-ops when there is no
|
||||
/// generating assistant message. Mirrors the web chat store's
|
||||
/// reconnectIfGenerating() helper.
|
||||
Future<void> attachToGeneration() async {
|
||||
final convId = _convId;
|
||||
final repo = ref.read(chatRepositoryProvider);
|
||||
|
||||
// Make sure we're looking at fresh server state before deciding whether
|
||||
// to attach. The provider's build() fetches once; if the conversation
|
||||
// was seeded via a POST that happened between build() and this call the
|
||||
// generating placeholder won't be in our in-memory list yet.
|
||||
try {
|
||||
final (_, fresh) = await repo.getMessages(convId);
|
||||
state = AsyncData(fresh);
|
||||
} catch (_) {
|
||||
// If we can't load messages we can't attach either — bail cleanly.
|
||||
return;
|
||||
}
|
||||
|
||||
if (ref.read(isStreamingProvider(convId))) return;
|
||||
final msgs = state.value ?? const <Message>[];
|
||||
final hasGeneratingAssistant = msgs.any(
|
||||
(m) => m.role == MessageRole.assistant && m.status == 'generating',
|
||||
);
|
||||
if (!hasGeneratingAssistant) return;
|
||||
|
||||
ref.read(isStreamingProvider(convId).notifier).state = true;
|
||||
|
||||
const stallTimeout = Duration(seconds: 45);
|
||||
bool streamedContent = false;
|
||||
final iter = StreamIterator(repo.streamGeneration(convId));
|
||||
try {
|
||||
while (await iter.moveNext().timeout(stallTimeout)) {
|
||||
final event = iter.current;
|
||||
if (event is ChatTextChunk) {
|
||||
streamedContent = true;
|
||||
ref.read(streamingStatusProvider(convId).notifier).state = '';
|
||||
final cur = state.requireValue;
|
||||
if (cur.isEmpty) continue;
|
||||
// Route text chunks into the generating assistant message. The
|
||||
// last message is usually the placeholder, but tool-call fan-in
|
||||
// means we can't rely on that universally.
|
||||
final idx = _findGeneratingAssistantIndex(cur);
|
||||
if (idx < 0) continue;
|
||||
final updated = cur[idx].copyWith(content: cur[idx].content + event.text);
|
||||
state = AsyncData([
|
||||
...cur.sublist(0, idx),
|
||||
updated,
|
||||
...cur.sublist(idx + 1),
|
||||
]);
|
||||
} else if (event is ChatStatusUpdate) {
|
||||
ref.read(streamingStatusProvider(convId).notifier).state = event.status;
|
||||
} else if (event is ChatToolCall) {
|
||||
final cur = state.requireValue;
|
||||
if (cur.isEmpty) continue;
|
||||
final idx = _findGeneratingAssistantIndex(cur);
|
||||
if (idx < 0) continue;
|
||||
final nextCalls = [...?cur[idx].toolCalls, event.toolCall];
|
||||
final updated = cur[idx].copyWith(toolCalls: nextCalls);
|
||||
state = AsyncData([
|
||||
...cur.sublist(0, idx),
|
||||
updated,
|
||||
...cur.sublist(idx + 1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
} on TimeoutException {
|
||||
// Stall — fall through to polling.
|
||||
} catch (_) {
|
||||
// Stream failed — fall through to polling.
|
||||
} finally {
|
||||
await iter.cancel();
|
||||
}
|
||||
|
||||
try {
|
||||
for (var attempt = 0; attempt < 20; attempt++) {
|
||||
if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
|
||||
final (_, fresh) = await repo.getMessages(convId);
|
||||
final done = fresh.any(
|
||||
(m) => m.role == MessageRole.assistant && m.status != 'generating',
|
||||
);
|
||||
final polledHasContent = fresh.any(
|
||||
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
|
||||
);
|
||||
if (!streamedContent || done || polledHasContent) {
|
||||
state = AsyncData(fresh);
|
||||
}
|
||||
if (done) break;
|
||||
}
|
||||
} catch (_) {
|
||||
// Give up silently — user can pull-to-refresh.
|
||||
} finally {
|
||||
ref.read(isStreamingProvider(convId).notifier).state = false;
|
||||
ref.read(streamingStatusProvider(convId).notifier).state = '';
|
||||
}
|
||||
}
|
||||
|
||||
int _findGeneratingAssistantIndex(List<Message> msgs) {
|
||||
for (var i = msgs.length - 1; i >= 0; i--) {
|
||||
final m = msgs[i];
|
||||
if (m.role == MessageRole.assistant && m.status == 'generating') return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String content) async {
|
||||
final convId = _convId;
|
||||
final repo = ref.read(chatRepositoryProvider);
|
||||
|
||||
Reference in New Issue
Block a user