fix: defer canvas agent view during interactions

This commit is contained in:
134 2026-06-23 19:03:47 +08:00
parent a1a095fe0e
commit 2e386cb931
3 changed files with 153 additions and 1 deletions

View File

@ -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<CanvasViewOperationStreamPayload | null>(null);
const canvasViewAnimationFrameRef = useRef<number | null>(null);
const canvasViewRetryTimerRef = useRef<number | null>(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(

View File

@ -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;
}

View File

@ -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();