From 9229896ad76ae9d6f8a4126ebeec49bde639a60d Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 23 Jul 2026 17:29:02 -0700 Subject: [PATCH] harden PWA resume and title fallback --- docs/api/rest.md | 1 + server/src/routes.ts | 13 +++++++++++- web/public/sw.js | 16 +++++++++++++- web/src/App.tsx | 50 ++++++++++++++++++++------------------------ web/src/pwa.ts | 8 ------- 5 files changed, 51 insertions(+), 37 deletions(-) diff --git a/docs/api/rest.md b/docs/api/rest.md index 79d68db..abfe418 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -186,6 +186,7 @@ Behavior notes: - If the chat already has a non-empty title, server returns the existing chat unchanged. - If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title. - When no title exists at write time, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat. +- If the title provider is unavailable or rejects the request, server still persists a deterministic title derived from the first line of `content` instead of leaving the chat untitled. ### `DELETE /v1/chats/:chatId` - Response: `{ "deleted": true }` diff --git a/server/src/routes.ts b/server/src/routes.ts index 317802b..e14613f 100644 --- a/server/src/routes.ts +++ b/server/src/routes.ts @@ -1007,7 +1007,18 @@ export async function registerRoutes(app: FastifyInstance) { if (existing.title?.trim()) return { chat: serializeChatLike(existing) }; const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat"; - const suggestedRaw = await generateChatTitle(body.content); + let suggestedRaw = ""; + try { + suggestedRaw = await generateChatTitle(body.content); + } catch (err) { + req.log.warn( + { + chatId: body.chatId, + err: getErrorMessage(err), + }, + "chat title generation failed; using fallback" + ); + } const title = normalizeSuggestedTitle(suggestedRaw, fallback); await prisma.chat.updateMany({ diff --git a/web/public/sw.js b/web/public/sw.js index 8e16e0d..e2f33fd 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -3,7 +3,21 @@ self.addEventListener("install", () => { }); self.addEventListener("activate", (event) => { - event.waitUntil(self.clients.claim()); + event.waitUntil( + (async () => { + await self.clients.claim(); + const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + await Promise.all( + windows.map(async (client) => { + try { + await client.navigate(client.url); + } catch { + // The client may have closed while the new worker was activating. + } + }) + ); + })() + ); }); self.addEventListener("fetch", (event) => { diff --git a/web/src/App.tsx b/web/src/App.tsx index 8dccb5d..191ebd8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -162,20 +162,18 @@ const EMPTY_ACTIVE_RUNS: ActiveRunsState = { function waitForAppForeground() { if (typeof document === "undefined" || typeof window === "undefined") return Promise.resolve(); - if (document.visibilityState !== "hidden" && navigator.onLine !== false) return Promise.resolve(); + if (document.visibilityState !== "hidden") return Promise.resolve(); return new Promise((resolve) => { const finishIfReady = () => { - if (document.visibilityState === "hidden" || navigator.onLine === false) return; + if (document.visibilityState === "hidden") return; document.removeEventListener("visibilitychange", finishIfReady); window.removeEventListener("pageshow", finishIfReady); - window.removeEventListener("online", finishIfReady); resolve(); }; document.addEventListener("visibilitychange", finishIfReady); window.addEventListener("pageshow", finishIfReady); - window.addEventListener("online", finishIfReady); }); } @@ -1179,7 +1177,7 @@ export default function App() { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { handleAuthFailure(message); - } else { + } else if (!isRecoverableStreamDisconnect(err)) { setError(message); } } finally { @@ -1197,7 +1195,7 @@ export default function App() { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { handleAuthFailure(message); - } else { + } else if (!isRecoverableStreamDisconnect(err)) { setError(message); } } finally { @@ -1234,7 +1232,15 @@ export default function App() { suspendChatStreams(); return; } + setError((current) => (current && isRecoverableStreamDisconnect(new Error(current)) ? null : current)); void refreshActiveRuns(); + void refreshCollections(selectedItemRef.current ?? undefined, false); + const currentSelection = selectedItemRef.current; + if (currentSelection?.kind === "chat") { + void refreshChat(currentSelection.id); + } else if (currentSelection?.kind === "search") { + void refreshSearch(currentSelection.id); + } }; const handlePageShow = () => { void refreshActiveRuns(); @@ -2216,6 +2222,7 @@ export default function App() { while (true) { let streamErrorMessage: string | null = null; + let replayedAssistantText = ""; const abortController = new AbortController(); chatStreamAbortRefs.current.set(chatId, abortController); @@ -2246,6 +2253,7 @@ export default function App() { }, onDelta: (payload) => { if (!payload.text) return; + replayedAssistantText += payload.text; setPendingChatStates((current) => { const pendingState = current[chatId]; if (!pendingState) return current; @@ -2254,7 +2262,7 @@ export default function App() { const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-"); if (!isTarget) return message; updated = true; - return { ...message, content: message.content + payload.text }; + return { ...message, content: replayedAssistantText }; }); return updated ? { ...current, [chatId]: { messages: nextMessages } } : current; }); @@ -2312,35 +2320,23 @@ export default function App() { if (shouldResumeStream) { try { const persistedChat = await retryAfterAppResume(() => getChat(chatId)); - const userMessageWasAccepted = hasNewPersistedUserMessage( - persistedChat, - previousMessageIds, - content, - attachments - ); setError(null); if (isCurrentSelection(target)) { setSelectedChat(persistedChat); setSelectedSearch(null); } - setPendingChatStates((current) => ({ - ...current, - [chatId]: { - messages: persistedChat.messages.concat( - ...(userMessageWasAccepted ? [] : [optimisticUserMessage]), - { - ...optimisticAssistantMessage, - id: `temp-assistant-resume-${Date.now()}`, - content: "", - } - ), - }, - })); // Retrying the same idempotent request either starts an unaccepted - // submission or replays the existing backend-owned stream. + // submission or replays the existing backend-owned stream. Keep + // the current optimistic transcript visible until replay begins. continue; } catch (resumeError) { + if (isRecoverableStreamDisconnect(resumeError)) { + setError(null); + await waitForAppForeground(); + await waitForRetry(1000); + continue; + } err = resumeError; } } else if (streamErrorMessage) { diff --git a/web/src/pwa.ts b/web/src/pwa.ts index 8677471..2d76b37 100644 --- a/web/src/pwa.ts +++ b/web/src/pwa.ts @@ -2,14 +2,6 @@ export function registerServiceWorker() { if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return; window.addEventListener("load", () => { - const hadController = Boolean(navigator.serviceWorker.controller); - let isReloadingForUpdate = false; - navigator.serviceWorker.addEventListener("controllerchange", () => { - if (!hadController || isReloadingForUpdate) return; - isReloadingForUpdate = true; - window.location.reload(); - }); - void navigator.serviceWorker .register("/sw.js", { updateViaCache: "none" }) .then((registration) => registration.update())