From 86c565d34c8e8c5a03ee69c2963ec2862195f9c6 Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 15 Oct 2026 14:50:43 +0800 Subject: [PATCH] fix(canvas): prevent stale autosaves from overwriting generated images --- components/canvas/CanvasChatPanel.tsx | 27 ++++++++++++++++++++++-- services/canvas/index.ts | 24 ++++----------------- tests/routes/canvas-chat-helpers.test.ts | 26 ++++++++++++++--------- 3 files changed, 45 insertions(+), 32 deletions(-) diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 11b4255ee..a60e97d74 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -15320,17 +15320,40 @@ export function CanvasChatPanel({ }, [activeChatSessionMode, startNewChatSession]); const switchToSession = useCallback((sessionId: string) => { - if (!sessionId || sessionId === activeChatId) { + if (!sessionId) { setIsSessionMenuOpen(false); return; } + if (sessionId === activeChatId) { + setIsSessionMenuOpen(false); + if (status === "streaming" || status === "submitted" || isClawdSubmitting) return; + // Reopening the current conversation must recover an empty/stale view too. + void mutateHistoryData().then((fresh) => { + if ( + activeChatIdRef.current !== sessionId || + inFlightChatIdRef.current === sessionId || + !Array.isArray(fresh?.messages) + ) return; + loadedHistorySignatureRef.current = buildCanvasHistoryMessagesSignature( + toRawCanvasChatId(sessionId), fresh.messages + ); + processedMessageIds.current.clear(); + hasHydratedHistoryRef.current = true; + setMessages(sortCanvasMessagesByCreatedAt(fresh.messages)); + setMessageViewportKey((previous) => previous + 1); + }).catch((error) => { + console.error("[CanvasChat] Failed to reopen current conversation:", error); + toast.error("聊天记录暂时读取失败,请重试"); + }); + return; + } forceHistoryHydrationRef.current = toRawCanvasChatId(sessionId); releaseSendGuard(activeChatId); setActiveChatId(sessionId); replaceChatSessionQuery(sessionId); setIsSessionMenuOpen(false); toast.success("已切换聊天窗口"); - }, [activeChatId, releaseSendGuard, replaceChatSessionQuery]); + }, [activeChatId, isClawdSubmitting, mutateHistoryData, releaseSendGuard, replaceChatSessionQuery, setMessages, status]); const switchToClawdHistorySession = useCallback( (historyItem: ClawdHistoryItem) => { diff --git a/services/canvas/index.ts b/services/canvas/index.ts index 1652f8575..27b7749de 100644 --- a/services/canvas/index.ts +++ b/services/canvas/index.ts @@ -306,26 +306,10 @@ export const canvasService = { return response; }; - let response = await doSaveWithRateLimitRetry(buildRequestBody(options)); - let data = await response.json().catch(() => ({})); - - const serverRevision = normalizeRevisionText(data?.canvasUpdatedAt); - if ( - response.status === 409 && - data?.code === "STALE_CANVAS_REVISION" && - serverRevision - ) { - response = await doSaveWithRateLimitRetry( - buildRequestBody( - { - baseCanvasRevision: serverRevision, - baseCanvasUpdatedAt: serverRevision, - }, - false - ) - ); - data = await response.json().catch(() => ({})); - } + const response = await doSaveWithRateLimitRetry(buildRequestBody(options)); + const data = await response.json().catch(() => ({})); + // A conflict means this snapshot missed a remote edit. Reusing the newer + // revision with the same old nodes would overwrite generated results. if (!response.ok) { return { diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 3f3dc26ee..54aba7f1e 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -896,12 +896,16 @@ test.describe("canvas chat pure helpers", () => { } }); - test("canvas service automatically retries stale save conflicts", async () => { + test("stale autosaves cannot overwrite a remotely generated image by adopting its revision", async () => { const originalFetch = globalThis.fetch; const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, "window"); const originalWindow = (globalThis as typeof globalThis & { window?: unknown }) .window; const bodies: Array> = []; + let remoteNodes = [{ id: "new-generated-image", type: "image" }]; + useCanvasStore.getState().setCanvasRevision( + "2026-06-22T00:01:00.000Z", "2026-06-22T00:01:00.000Z" + ); const responses = [ { ok: false, @@ -929,7 +933,9 @@ test.describe("canvas chat pure helpers", () => { writable: true, }); globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - bodies.push(JSON.parse(String(init?.body || "{}"))); + const body = JSON.parse(String(init?.body || "{}")); + bodies.push(body); + if (bodies.length > 1) remoteNodes = body.canvasData.nodes; return responses.shift() as Response; }) as typeof fetch; @@ -946,16 +952,15 @@ test.describe("canvas chat pure helpers", () => { ); expect(result).toMatchObject({ - success: true, - data: { updatedAt: "2026-06-22T00:02:01.000Z" }, - }); - expect(bodies).toHaveLength(2); - expect(bodies[1]).toMatchObject({ - baseCanvasRevision: "2026-06-22T00:02:00.000Z", - baseCanvasUpdatedAt: "2026-06-22T00:02:00.000Z", + success: false, + status: 409, + code: "STALE_CANVAS_REVISION", + canvasUpdatedAt: "2026-06-22T00:02:00.000Z", }); + expect(bodies).toHaveLength(1); + expect(remoteNodes).toEqual([{ id: "new-generated-image", type: "image" }]); expect(useCanvasStore.getState().canvasRevision).toBe( - "2026-06-22T00:02:01.000Z" + "2026-06-22T00:01:00.000Z" ); } finally { globalThis.fetch = originalFetch; @@ -968,6 +973,7 @@ test.describe("canvas chat pure helpers", () => { } else { Reflect.deleteProperty(globalThis, "window"); } + useCanvasStore.getState().setCanvasRevision(null, null); } });