From dfb8971bcbf269ccbaada9980c25d3689fda3b97 Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 22 Jun 2026 22:42:13 +0800 Subject: [PATCH] fix: retry canvas agent view after refresh --- components/canvas/CanvasChatPanel.tsx | 29 +++++++++++++++---- .../canvas/chat-panel/canvas-agent-stream.ts | 16 ++++++++++ tests/routes/canvas-chat-helpers.test.ts | 26 +++++++++++++++++ 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 8e6247687..8c27f7154 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -129,6 +129,7 @@ import { getCanvasMediaPayloadHandledItemIds, getCanvasOperationHandledMediaItemIds, type ImageGenerationStatusStreamPayload, + shouldRetryCanvasViewOperationAfterRefresh, shouldSkipCanvasMediaPayloadForHandledItems, } from "@/components/canvas/chat-panel/canvas-agent-stream"; import { @@ -1249,6 +1250,8 @@ export function CanvasChatPanel({ const autoContextRef = useRef(null); const contextPayloadRef = useRef(null); const handledCanvasMediaItemIdsRef = useRef>(new Set()); + const pendingCanvasViewOperationRef = + useRef(null); const lastWorkflowAutofillSignatureRef = useRef(null); const lastWorkflowAutofillUrlsRef = useRef([]); const pendingLaunchRef = useRef(null); @@ -1826,10 +1829,10 @@ export function CanvasChatPanel({ const applyCanvasViewOperation = useCallback( (payload: CanvasViewOperationStreamPayload) => { - if (!payload || typeof payload !== "object") return; + if (!payload || typeof payload !== "object") return false; const targetProjectId = String(payload.projectId || "").trim(); - if (targetProjectId && targetProjectId !== canvasId) return; - if (typeof window === "undefined") return; + if (targetProjectId && targetProjectId !== canvasId) return false; + if (typeof window === "undefined") return false; const store = useCanvasStore.getState(); const resolved = resolveCanvasViewOperation({ @@ -1842,7 +1845,7 @@ export function CanvasChatPanel({ height: window.innerHeight, }, }); - if (!resolved) return; + if (!resolved) return false; window.requestAnimationFrame(() => { setTransientViewport(resolved.viewport); @@ -1850,6 +1853,7 @@ export function CanvasChatPanel({ if (resolved.targetNodeIds.length > 0) { store.selectNodes(resolved.targetNodeIds); } + return true; }, [canvasId, setTransientViewport] ); @@ -4062,6 +4066,16 @@ export function CanvasChatPanel({ focusCanvasOnNode(focusNode); } } + const pendingViewOperation = pendingCanvasViewOperationRef.current; + if (pendingViewOperation) { + const pendingProjectId = String(pendingViewOperation.projectId || "").trim(); + if (!pendingProjectId || pendingProjectId === targetProjectId) { + const applied = applyCanvasViewOperation(pendingViewOperation); + if (applied) { + pendingCanvasViewOperationRef.current = null; + } + } + } return true; } catch (error) { @@ -4071,7 +4085,7 @@ export function CanvasChatPanel({ return false; } }, - [canvasId, focusCanvasOnNode, mutate] + [applyCanvasViewOperation, canvasId, focusCanvasOnNode, mutate] ); const { @@ -4242,7 +4256,10 @@ export function CanvasChatPanel({ try { const payload: CanvasViewOperationStreamPayload = typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data; - applyCanvasViewOperation(payload); + const applied = applyCanvasViewOperation(payload); + if (!applied && shouldRetryCanvasViewOperationAfterRefresh(payload)) { + pendingCanvasViewOperationRef.current = payload; + } } catch (e) { console.error("[CanvasChat] Failed to parse data-canvasViewOperation:", e); } diff --git a/components/canvas/chat-panel/canvas-agent-stream.ts b/components/canvas/chat-panel/canvas-agent-stream.ts index 301666765..e8245b7bf 100644 --- a/components/canvas/chat-panel/canvas-agent-stream.ts +++ b/components/canvas/chat-panel/canvas-agent-stream.ts @@ -64,6 +64,22 @@ export function shouldSkipCanvasMediaPayloadForHandledItems(params: { ); } +export function shouldRetryCanvasViewOperationAfterRefresh( + payload: CanvasViewOperationStreamPayload +) { + const operation = String(payload?.operation || "").trim(); + if (!operation) return false; + if (normalizeCanvasStreamIdList(payload.nodeIds).length > 0) return true; + if (String(payload.anchorNodeId || "").trim()) return true; + return ( + operation === "focus_node" || + operation === "focus_nodes" || + operation === "focus_selection" || + operation === "focus_latest_result" || + operation === "fit_all" + ); +} + export type ImageGenerationStatusStreamPayload = { phase?: "queued" | "processing" | "finalizing" | "failed" | "completed"; providerType?: string; diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index ab4ddc049..63c1124d8 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -9,6 +9,7 @@ import { import { getCanvasMediaPayloadHandledItemIds, getCanvasOperationHandledMediaItemIds, + shouldRetryCanvasViewOperationAfterRefresh, shouldSkipCanvasMediaPayloadForHandledItems, } from "@/components/canvas/chat-panel/canvas-agent-stream"; import { @@ -1217,6 +1218,31 @@ test.describe("canvas chat pure helpers", () => { expect(useCanvasStore.getState().isAgentOperationRefreshActive()).toBe(false); }); + test("queues target-based canvas view operations for post-refresh retry", () => { + expect( + shouldRetryCanvasViewOperationAfterRefresh({ + version: 1, + viewOperationId: "view-result", + operation: "focus_latest_result", + nodeIds: ["new-image-1"], + }) + ).toBe(true); + expect( + shouldRetryCanvasViewOperationAfterRefresh({ + version: 1, + viewOperationId: "view-fit-all", + operation: "fit_all", + }) + ).toBe(true); + expect( + shouldRetryCanvasViewOperationAfterRefresh({ + version: 1, + viewOperationId: "view-zoom", + operation: "zoom_in", + }) + ).toBe(false); + }); + test("builds transient view operations for agent writeback results", () => { expect( buildCanvasAgentResultViewOperation({