fix: defer canvas agent view during interactions
This commit is contained in:
parent
a1a095fe0e
commit
2e386cb931
@ -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(
|
||||
|
||||
56
lib/canvas/agent-view-interaction-guard.ts
Normal file
56
lib/canvas/agent-view-interaction-guard.ts
Normal 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;
|
||||
}
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user