diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 777359d88..de8cc5224 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -103,6 +103,12 @@ import { interpolateCanvasAgentViewport, normalizeCanvasAgentViewAnimationDuration, } from "@/lib/canvas/agent-view-animation"; +import { + CANVAS_AGENT_VIEW_INTERACTION_DEFER_MS, + isCanvasAgentViewEditableElement, + isCanvasAgentViewOperationTarget, + shouldDeferCanvasAgentViewOperation, +} from "@/lib/canvas/agent-view-interaction-guard"; import { getCanvasAgentOperationErrorMessage } from "@/lib/canvas/agent-operation-errors"; import { getCanvasSkillIconComponent } from "@/components/canvas/canvas-skill-icons"; import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar"; @@ -1272,6 +1278,9 @@ export function CanvasChatPanel({ const pendingCanvasViewOperationRef = useRef(null); const canvasViewAnimationFrameRef = useRef(null); + const canvasViewRetryTimerRef = useRef(null); + const canvasViewPointerDownRef = useRef(false); + const lastCanvasViewPointerInteractionAtRef = useRef(0); const pendingCanvasAgentAnnotationEditTargetRef = useRef<{ nodeId: string; requestedAt: number; @@ -1907,8 +1916,29 @@ export function CanvasChatPanel({ [setTransientViewport] ); + const isCanvasAgentViewInteractionBusy = useCallback(() => { + if (typeof document === "undefined") return false; + const rootElement = document.querySelector('[data-canvas-workspace-root="true"]'); + return shouldDeferCanvasAgentViewOperation({ + activeEditable: isCanvasAgentViewEditableElement({ + activeElement: document.activeElement, + rootElement, + }), + lastPointerInteractionAt: lastCanvasViewPointerInteractionAtRef.current, + pointerDown: canvasViewPointerDownRef.current, + }); + }, []); + useEffect(() => { + const clearRetryTimer = () => { + if (canvasViewRetryTimerRef.current !== null) { + window.clearTimeout(canvasViewRetryTimerRef.current); + canvasViewRetryTimerRef.current = null; + } + }; + return () => { + clearRetryTimer(); if ( typeof window !== "undefined" && canvasViewAnimationFrameRef.current !== null @@ -1919,6 +1949,33 @@ export function CanvasChatPanel({ }; }, []); + useEffect(() => { + if (typeof window === "undefined") return; + + const markCanvasPointerInteraction = (event: PointerEvent) => { + if (!isCanvasAgentViewOperationTarget(event.target)) return; + canvasViewPointerDownRef.current = true; + lastCanvasViewPointerInteractionAtRef.current = Date.now(); + }; + const releaseCanvasPointerInteraction = () => { + if (!canvasViewPointerDownRef.current) return; + canvasViewPointerDownRef.current = false; + lastCanvasViewPointerInteractionAtRef.current = Date.now(); + }; + + window.addEventListener("pointerdown", markCanvasPointerInteraction, true); + window.addEventListener("pointerup", releaseCanvasPointerInteraction, true); + window.addEventListener("pointercancel", releaseCanvasPointerInteraction, true); + window.addEventListener("blur", releaseCanvasPointerInteraction); + + return () => { + window.removeEventListener("pointerdown", markCanvasPointerInteraction, true); + window.removeEventListener("pointerup", releaseCanvasPointerInteraction, true); + window.removeEventListener("pointercancel", releaseCanvasPointerInteraction, true); + window.removeEventListener("blur", releaseCanvasPointerInteraction); + }; + }, []); + const applyCanvasViewOperation = useCallback( (payload: CanvasViewOperationStreamPayload) => { if (!payload || typeof payload !== "object") return false; @@ -1926,6 +1983,23 @@ export function CanvasChatPanel({ if (targetProjectId && targetProjectId !== canvasId) return false; if (typeof window === "undefined") return false; + if (isCanvasAgentViewInteractionBusy()) { + pendingCanvasViewOperationRef.current = payload; + if (canvasViewRetryTimerRef.current === null) { + canvasViewRetryTimerRef.current = window.setTimeout(() => { + canvasViewRetryTimerRef.current = null; + const pendingViewOperation = pendingCanvasViewOperationRef.current; + if (!pendingViewOperation) return; + pendingCanvasViewOperationRef.current = null; + const applied = applyCanvasViewOperation(pendingViewOperation); + if (!applied) { + pendingCanvasViewOperationRef.current = pendingViewOperation; + } + }, CANVAS_AGENT_VIEW_INTERACTION_DEFER_MS); + } + return false; + } + const store = useCanvasStore.getState(); const resolved = resolveCanvasViewOperation({ plan: payload, @@ -1945,7 +2019,7 @@ export function CanvasChatPanel({ } return true; }, - [animateCanvasAgentViewport, canvasId] + [animateCanvasAgentViewport, canvasId, isCanvasAgentViewInteractionBusy] ); const handleAssistantVote = useCallback( diff --git a/lib/canvas/agent-view-interaction-guard.ts b/lib/canvas/agent-view-interaction-guard.ts new file mode 100644 index 000000000..ae12de476 --- /dev/null +++ b/lib/canvas/agent-view-interaction-guard.ts @@ -0,0 +1,56 @@ +export const CANVAS_AGENT_VIEW_INTERACTION_DEFER_MS = 700; + +const CANVAS_AGENT_VIEW_OPERATION_TARGET_SELECTOR = [ + '[data-canvas-workspace-root="true"]', + "[data-canvas-node-id]", + "[data-canvas-overlay]", +].join(", "); + +const EDITABLE_SELECTOR = [ + "input", + "textarea", + "select", + '[contenteditable="true"]', + '[role="textbox"]', +].join(", "); + +export function isCanvasAgentViewOperationTarget(target: EventTarget | null) { + return target instanceof Element + ? Boolean(target.closest(CANVAS_AGENT_VIEW_OPERATION_TARGET_SELECTOR)) + : false; +} + +export function isCanvasAgentViewEditableElement(params: { + activeElement?: Element | null; + rootElement?: Element | null; +}) { + const activeElement = params.activeElement; + if (!(activeElement instanceof HTMLElement)) return false; + if (params.rootElement && !params.rootElement.contains(activeElement)) { + return false; + } + return activeElement.isContentEditable || Boolean(activeElement.closest(EDITABLE_SELECTOR)); +} + +export function shouldDeferCanvasAgentViewOperation(params: { + activeEditable?: boolean; + deferMs?: number; + lastPointerInteractionAt?: number | null; + now?: number; + pointerDown?: boolean; +}) { + if (params.pointerDown) return true; + if (params.activeEditable) return true; + + const lastPointerInteractionAt = Number(params.lastPointerInteractionAt || 0); + if (lastPointerInteractionAt <= 0) return false; + + const now = Number.isFinite(Number(params.now)) ? Number(params.now) : Date.now(); + const deferMs = Math.max( + 0, + Number.isFinite(Number(params.deferMs)) + ? Number(params.deferMs) + : CANVAS_AGENT_VIEW_INTERACTION_DEFER_MS + ); + return now - lastPointerInteractionAt < deferMs; +} diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 8b2999f78..ea712f1f3 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -94,6 +94,10 @@ import { interpolateCanvasAgentViewport, normalizeCanvasAgentViewAnimationDuration, } from "@/lib/canvas/agent-view-animation"; +import { + CANVAS_AGENT_VIEW_INTERACTION_DEFER_MS, + shouldDeferCanvasAgentViewOperation, +} from "@/lib/canvas/agent-view-interaction-guard"; import { getCanvasAgentOperationErrorMessage, normalizeCanvasAgentOperationErrorCode, @@ -2081,6 +2085,24 @@ test.describe("canvas chat pure helpers", () => { ).toEqual({ x: 87.5, y: 195, zoom: 1.875 }); }); + test("defers canvas agent view operations during active canvas interactions", () => { + expect(shouldDeferCanvasAgentViewOperation({ pointerDown: true })).toBe(true); + expect(shouldDeferCanvasAgentViewOperation({ activeEditable: true })).toBe(true); + expect( + shouldDeferCanvasAgentViewOperation({ + lastPointerInteractionAt: 1000, + now: 1000 + CANVAS_AGENT_VIEW_INTERACTION_DEFER_MS - 1, + }) + ).toBe(true); + expect( + shouldDeferCanvasAgentViewOperation({ + lastPointerInteractionAt: 1000, + now: 1000 + CANVAS_AGENT_VIEW_INTERACTION_DEFER_MS, + }) + ).toBe(false); + expect(shouldDeferCanvasAgentViewOperation({ now: 2000 })).toBe(false); + }); + test("marks agent operation refresh windows for autosave guards", () => { const store = useCanvasStore.getState(); store.clearAgentOperationRefresh();