Persist streamed assistant answers even if the client disconnects

The first message in a new conversation navigated to /c/:id, which remounted
ChatScreen (two separate routes) and aborted the in-flight /stream-query fetch.
Server-side that surfaced as asyncio.CancelledError, which isn't an Exception,
so the except branch missed it and skipped the add_message_to_conversation
save — the fully-streamed answer was never persisted.

Backend: handle CancelledError and persist via a detached, shielded task so a
disconnect at any point (navigation, tab close, network blip) still commits the
answer. Frontend: serve "/" and "/c/:id" from a single catch-all ChatScreen
route so first-message navigation no longer remounts and aborts the stream;
derive the conversation id via useMatch instead of useParams.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 23:28:45 -04:00
parent 53f76f58ea
commit 4fe5b8daea
4 changed files with 54 additions and 20 deletions
+10 -10
View File
@@ -1,4 +1,4 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { Route, Routes } from "react-router-dom";
import "./App.css";
import { AuthProvider } from "./contexts/AuthContext";
import { ChatScreen } from "./components/ChatScreen";
@@ -31,16 +31,16 @@ const AppContainer = () => {
return (
<Routes>
<Route
path="/"
element={<ChatScreen setAuthenticated={setAuthenticated} isAdmin={isAdmin} />}
/>
<Route
path="/c/:conversationId"
element={<ChatScreen setAuthenticated={setAuthenticated} isAdmin={isAdmin} />}
/>
<Route path="/conversations" element={<ConversationsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
{/* One ChatScreen instance serves both the home ("/") and per-conversation
("/c/:id") routes. Using a single catch-all route keeps that instance
mounted across the navigate() that fires when the first message creates
a conversation — a remount here would abort the in-flight SSE stream
(and drop the answer before it's persisted). */}
<Route
path="*"
element={<ChatScreen setAuthenticated={setAuthenticated} isAdmin={isAdmin} />}
/>
</Routes>
);
};
+5 -2
View File
@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useParams } from "react-router-dom";
import { useMatch } from "react-router-dom";
import { conversationService } from "../api/conversationService";
import type { Conversation } from "./useConversations";
@@ -42,7 +42,10 @@ export function useChat({
onSessionExpired,
scrollToBottom,
}: UseChatOptions) {
const { conversationId } = useParams();
// ChatScreen renders under a single catch-all route so it isn't remounted
// when the first message navigates to /c/:id (which would abort the stream),
// so the id comes from matching the URL rather than useParams.
const conversationId = useMatch("/c/:conversationId")?.params.conversationId;
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [messagesLoading, setMessagesLoading] = useState(false);
+4 -2
View File
@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useMatch, useNavigate } from "react-router-dom";
import { conversationService } from "../api/conversationService";
export type Conversation = {
@@ -13,7 +13,9 @@ const SIDEBAR_LIMIT = 10;
export function useConversations() {
const navigate = useNavigate();
const { conversationId } = useParams();
// ChatScreen now renders under a single catch-all route, so useParams no
// longer captures the id. Derive it from the URL directly instead.
const conversationId = useMatch("/c/:conversationId")?.params.conversationId;
const [conversations, setConversations] = useState<Conversation[]>([]);
const refreshConversations = useCallback(async () => {