harden PWA resume and title fallback

This commit is contained in:
2026-07-23 17:29:02 -07:00
parent c5217b2710
commit 9229896ad7
5 changed files with 51 additions and 37 deletions

View File

@@ -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 }`

View File

@@ -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({

View File

@@ -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) => {

View File

@@ -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<void>((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) {

View File

@@ -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())