fix(canvas): prevent stale autosaves from overwriting generated images
This commit is contained in:
parent
e6dbb612ce
commit
86c565d34c
@ -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) => {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<Record<string, unknown>> = [];
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user