make PWA chat resume idempotent

This commit is contained in:
2026-07-23 17:12:11 -07:00
parent abc1124d27
commit c5217b2710
10 changed files with 326 additions and 140 deletions

View File

@@ -8,5 +8,5 @@ self.addEventListener("activate", (event) => {
self.addEventListener("fetch", (event) => {
if (event.request.mode !== "navigate") return;
event.respondWith(fetch(event.request));
event.respondWith(fetch(new Request(event.request, { cache: "no-store" })));
});

View File

@@ -206,6 +206,13 @@ function getActiveRunsAfterResume() {
return retryAfterAppResume(getActiveRuns);
}
function createClientRequestId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `web-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function isRecoverableStreamDisconnect(error: unknown) {
if (error instanceof TypeError) return true;
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
@@ -1085,7 +1092,7 @@ export default function App() {
resetWorkspaceState();
};
const refreshCollections = async (preferredSelection?: SidebarSelection) => {
const refreshCollections = async (preferredSelection?: SidebarSelection, reportTransientError = true) => {
setIsLoadingCollections(true);
try {
const nextWorkspaceItems = await listWorkspaceItems();
@@ -1113,7 +1120,7 @@ export default function App() {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else {
} else if (reportTransientError || !isRecoverableStreamDisconnect(err)) {
setError(message);
}
} finally {
@@ -2204,141 +2211,166 @@ export default function App() {
});
}
let streamErrorMessage: string | null = null;
const abortController = new AbortController();
chatStreamAbortRefs.current.set(chatId, abortController);
const target: SidebarSelection = { kind: "chat", id: chatId };
const clientRequestId = createClientRequestId();
try {
await runCompletionStream(
{
chatId,
provider,
model: selectedModel,
messages: requestMessages,
},
{
onMeta: (payload) => {
if (payload.chatId !== chatId) return;
while (true) {
let streamErrorMessage: string | null = null;
const abortController = new AbortController();
chatStreamAbortRefs.current.set(chatId, abortController);
try {
await runCompletionStream(
{
chatId,
clientRequestId,
provider,
model: selectedModel,
messages: requestMessages,
},
onToolCall: (payload) => {
setPendingChatStates((current) => {
const pendingState = current[chatId];
if (!pendingState) return current;
return {
...current,
[chatId]: {
messages: upsertOptimisticToolMessage(pendingState.messages, payload, "temp-assistant-"),
},
};
});
},
onDelta: (payload) => {
if (!payload.text) return;
setPendingChatStates((current) => {
const pendingState = current[chatId];
if (!pendingState) return current;
let updated = false;
const nextMessages = pendingState.messages.map((message, index, all) => {
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-");
if (!isTarget) return message;
updated = true;
return { ...message, content: message.content + payload.text };
{
onMeta: (payload) => {
if (payload.chatId !== chatId) return;
},
onToolCall: (payload) => {
setPendingChatStates((current) => {
const pendingState = current[chatId];
if (!pendingState) return current;
return {
...current,
[chatId]: {
messages: upsertOptimisticToolMessage(pendingState.messages, payload, "temp-assistant-"),
},
};
});
return updated ? { ...current, [chatId]: { messages: nextMessages } } : current;
});
},
onDone: (payload) => {
setPendingChatStates((current) => {
const pendingState = current[chatId];
if (!pendingState) return current;
let updated = false;
const nextMessages = pendingState.messages.map((message, index, all) => {
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-");
if (!isTarget) return message;
updated = true;
return { ...message, content: payload.text };
},
onDelta: (payload) => {
if (!payload.text) return;
setPendingChatStates((current) => {
const pendingState = current[chatId];
if (!pendingState) return current;
let updated = false;
const nextMessages = pendingState.messages.map((message, index, all) => {
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 updated ? { ...current, [chatId]: { messages: nextMessages } } : current;
});
return updated ? { ...current, [chatId]: { messages: nextMessages } } : current;
});
},
onDone: (payload) => {
setPendingChatStates((current) => {
const pendingState = current[chatId];
if (!pendingState) return current;
let updated = false;
const nextMessages = pendingState.messages.map((message, index, all) => {
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-");
if (!isTarget) return message;
updated = true;
return { ...message, content: payload.text };
});
return updated ? { ...current, [chatId]: { messages: nextMessages } } : current;
});
},
onError: (payload) => {
streamErrorMessage = payload.message;
},
},
onError: (payload) => {
streamErrorMessage = payload.message;
},
},
{ signal: abortController.signal }
);
{ signal: abortController.signal }
);
if (streamErrorMessage) {
throw new Error(streamErrorMessage);
}
if (streamErrorMessage) {
throw new Error(streamErrorMessage);
}
backgroundSuspendedChatStreamsRef.current.delete(chatId);
await refreshCollections();
const currentSelection = selectedItemRef.current;
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
await refreshChat(chatId);
}
removePendingChatState(chatId);
removeActiveRun("chat", chatId);
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
requestSettleTranscriptTailSpacer();
}
return target;
} catch (err) {
const wasBackgroundSuspended =
abortController.signal.aborted && backgroundSuspendedChatStreamsRef.current.delete(chatId);
const shouldResumeStream =
wasBackgroundSuspended ||
(!abortController.signal.aborted && streamErrorMessage === null && isRecoverableStreamDisconnect(err));
backgroundSuspendedChatStreamsRef.current.delete(chatId);
const persistedChat = await retryAfterAppResume(() => getChat(chatId));
await refreshCollections(target, false);
if (isCurrentSelection(target)) {
setSelectedChat(persistedChat);
setSelectedSearch(null);
}
removePendingChatState(chatId);
removeActiveRun("chat", chatId);
if (isCurrentSelection(target)) {
requestSettleTranscriptTailSpacer();
}
return target;
} catch (caughtError) {
let err: unknown = caughtError;
const wasBackgroundSuspended =
abortController.signal.aborted && backgroundSuspendedChatStreamsRef.current.delete(chatId);
const shouldResumeStream =
wasBackgroundSuspended ||
(!abortController.signal.aborted && streamErrorMessage === null && isRecoverableStreamDisconnect(err));
if (shouldResumeStream) {
if (chatStreamAbortRefs.current.get(chatId) === abortController) {
chatStreamAbortRefs.current.delete(chatId);
}
try {
const resumedRuns = await getActiveRunsAfterResume();
setActiveRuns(buildActiveRunsState(resumedRuns));
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: "",
}
),
},
}));
if (resumedRuns.chats.includes(chatId)) {
removePendingChatState(chatId);
await attachToActiveChatStream(chatId, true);
return target;
// Retrying the same idempotent request either starts an unaccepted
// submission or replays the existing backend-owned stream.
continue;
} catch (resumeError) {
err = resumeError;
}
const persistedChat = await retryAfterAppResume(() => getChat(chatId));
await refreshCollections(target);
if (isCurrentSelection(target)) {
setSelectedChat(persistedChat);
setSelectedSearch(null);
} else if (streamErrorMessage) {
try {
const persistedChat = await retryAfterAppResume(() => getChat(chatId));
if (hasNewPersistedUserMessage(persistedChat, previousMessageIds, content, attachments)) {
if (isCurrentSelection(target)) {
setSelectedChat(persistedChat);
setSelectedSearch(null);
setError(streamErrorMessage);
}
removePendingChatState(chatId);
removeActiveRun("chat", chatId);
requestSettleTranscriptTailSpacer();
return target;
}
} catch (reconcileError) {
err = reconcileError;
}
removePendingChatState(chatId);
removeActiveRun("chat", chatId);
if (hasNewPersistedUserMessage(persistedChat, previousMessageIds, content, attachments)) {
requestSettleTranscriptTailSpacer();
return target;
}
err = new Error(
wasBackgroundSuspended
? "The app was backgrounded before the message reached the server. Please send it again."
: "The connection dropped before the message reached the server. Please send it again."
);
} catch (resumeError) {
err = resumeError;
}
}
removePendingChatState(chatId);
removeActiveRun("chat", chatId);
pendingReplyScrollRef.current = false;
setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP);
throw err;
} finally {
if (chatStreamAbortRefs.current.get(chatId) === abortController) {
chatStreamAbortRefs.current.delete(chatId);
removePendingChatState(chatId);
removeActiveRun("chat", chatId);
pendingReplyScrollRef.current = false;
setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP);
throw err;
} finally {
if (chatStreamAbortRefs.current.get(chatId) === abortController) {
chatStreamAbortRefs.current.delete(chatId);
}
}
}
};

View File

@@ -589,6 +589,7 @@ export async function runCompletionStream(
body: {
chatId?: string | null;
persist?: boolean;
clientRequestId?: string;
provider: Provider;
model: string;
messages: CompletionRequestMessage[];

View File

@@ -2,8 +2,19 @@ export function registerServiceWorker() {
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
window.addEventListener("load", () => {
void navigator.serviceWorker.register("/sw.js").catch((error: unknown) => {
console.warn("Sybil service worker registration failed", error);
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())
.catch((error: unknown) => {
console.warn("Sybil service worker registration failed", error);
});
});
}