mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-30 03:00:41 -04:00
fix(frontend/copilot): capture pointer during artifact panel resize
Dragging the resize handle broke whenever the cursor drifted over a sandboxed artifact iframe: the iframe swallowed pointermove/pointerup, so the parent document's listeners never heard the release and the drag got stuck. Reported as SECRT-2256: "Can't easily move it from left to right … sometimes can't move it at all to right when its expanded all the way to the left." "When you click it should stop moving." Fix: call setPointerCapture on the handle in pointerdown so every subsequent pointer event routes to the handle regardless of what's under the cursor. Release on pointerup/cancel. Also adds touch-action: none so touch drags don't fight page scrolling. Six tests cover: capture on down, release on up, drag-left/right math, min/max clamping, and drag actually stops on release. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ArtifactDragHandle } from "./ArtifactDragHandle";
|
||||
|
||||
function renderHandle(onWidthChange = vi.fn(), panelWidth = 600) {
|
||||
const utils = render(
|
||||
<div
|
||||
data-artifact-panel
|
||||
style={{
|
||||
width: `${panelWidth}px`,
|
||||
height: "400px",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<ArtifactDragHandle onWidthChange={onWidthChange} />
|
||||
</div>,
|
||||
);
|
||||
const panel = utils.container.querySelector(
|
||||
"[data-artifact-panel]",
|
||||
) as HTMLElement;
|
||||
// happy-dom doesn't compute layout; stub offsetWidth so the handle reads
|
||||
// the intended starting width.
|
||||
Object.defineProperty(panel, "offsetWidth", {
|
||||
value: panelWidth,
|
||||
configurable: true,
|
||||
});
|
||||
const handle = utils.container.querySelector(
|
||||
'[role="separator"]',
|
||||
) as HTMLElement;
|
||||
return { handle, onWidthChange, ...utils };
|
||||
}
|
||||
|
||||
// jsdom/happy-dom don't implement pointer capture by default — stub them so
|
||||
// the component can still exercise the capture calls.
|
||||
function installPointerCaptureStub() {
|
||||
const setPointerCapture = vi.fn();
|
||||
const releasePointerCapture = vi.fn();
|
||||
(
|
||||
HTMLElement.prototype as unknown as {
|
||||
setPointerCapture: typeof setPointerCapture;
|
||||
}
|
||||
).setPointerCapture = setPointerCapture;
|
||||
(
|
||||
HTMLElement.prototype as unknown as {
|
||||
releasePointerCapture: typeof releasePointerCapture;
|
||||
}
|
||||
).releasePointerCapture = releasePointerCapture;
|
||||
return { setPointerCapture, releasePointerCapture };
|
||||
}
|
||||
|
||||
describe("ArtifactDragHandle", () => {
|
||||
let spies: ReturnType<typeof installPointerCaptureStub>;
|
||||
|
||||
beforeEach(() => {
|
||||
spies = installPointerCaptureStub();
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
value: 1200,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// SECRT-2256: when the cursor drifts over a sandboxed iframe mid-drag, the
|
||||
// iframe eats pointermove/pointerup and the drag gets stuck. setPointerCapture
|
||||
// routes all subsequent pointer events to the handle regardless of what's
|
||||
// under the cursor, which fixes both "can't drag right" and "drag doesn't
|
||||
// stop on release".
|
||||
it("captures the pointer on pointerdown so drags survive the cursor drifting over iframes (SECRT-2256)", () => {
|
||||
const { handle } = renderHandle();
|
||||
|
||||
fireEvent.pointerDown(handle, { clientX: 500, pointerId: 7 });
|
||||
|
||||
expect(spies.setPointerCapture).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it("releases the pointer capture when the drag ends", () => {
|
||||
const { handle } = renderHandle();
|
||||
|
||||
fireEvent.pointerDown(handle, { clientX: 500, pointerId: 7 });
|
||||
fireEvent.pointerUp(handle, { clientX: 400, pointerId: 7 });
|
||||
|
||||
expect(spies.releasePointerCapture).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it("calls onWidthChange with the expanded width when dragging leftwards", () => {
|
||||
const onWidthChange = vi.fn();
|
||||
const { handle } = renderHandle(onWidthChange);
|
||||
|
||||
fireEvent.pointerDown(handle, { clientX: 800, pointerId: 1 });
|
||||
fireEvent.pointerMove(document, { clientX: 700, pointerId: 1 });
|
||||
|
||||
// startWidth is 600 (container), delta = 800 - 700 = 100 → newWidth 700
|
||||
expect(onWidthChange).toHaveBeenCalledWith(700);
|
||||
});
|
||||
|
||||
it("calls onWidthChange with the shrunk width when dragging rightwards", () => {
|
||||
const onWidthChange = vi.fn();
|
||||
const { handle } = renderHandle(onWidthChange);
|
||||
|
||||
fireEvent.pointerDown(handle, { clientX: 800, pointerId: 1 });
|
||||
fireEvent.pointerMove(document, { clientX: 900, pointerId: 1 });
|
||||
|
||||
// delta = -100 → newWidth 500
|
||||
expect(onWidthChange).toHaveBeenCalledWith(500);
|
||||
});
|
||||
|
||||
it("clamps to minWidth and maxWidth", () => {
|
||||
const onWidthChange = vi.fn();
|
||||
const { handle } = renderHandle(onWidthChange);
|
||||
|
||||
fireEvent.pointerDown(handle, { clientX: 800, pointerId: 1 });
|
||||
|
||||
// Drag way left → want huge width, should clamp at 85% of 1200 = 1020
|
||||
fireEvent.pointerMove(document, { clientX: -5000, pointerId: 1 });
|
||||
expect(onWidthChange).toHaveBeenLastCalledWith(1020);
|
||||
|
||||
// Drag way right → want tiny width, should clamp at minWidth 320
|
||||
fireEvent.pointerMove(document, { clientX: 5000, pointerId: 1 });
|
||||
expect(onWidthChange).toHaveBeenLastCalledWith(320);
|
||||
});
|
||||
|
||||
it("stops dragging on pointerup so subsequent cursor moves don't resize", () => {
|
||||
const onWidthChange = vi.fn();
|
||||
const { handle } = renderHandle(onWidthChange);
|
||||
|
||||
fireEvent.pointerDown(handle, { clientX: 800, pointerId: 1 });
|
||||
fireEvent.pointerUp(handle, { clientX: 800, pointerId: 1 });
|
||||
onWidthChange.mockClear();
|
||||
|
||||
fireEvent.pointerMove(document, { clientX: 500, pointerId: 1 });
|
||||
expect(onWidthChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,10 @@ export function ArtifactDragHandle({
|
||||
minWidthRef.current = minWidth;
|
||||
maxWidthPercentRef.current = maxWidthPercent;
|
||||
|
||||
// Track the captured pointer id so pointerup can release it even after
|
||||
// React re-renders.
|
||||
const pointerIdRef = useRef<number | null>(null);
|
||||
|
||||
// Attach document listeners only while dragging, and always tear them down
|
||||
// on unmount — otherwise closing the panel mid-drag leaves listeners bound
|
||||
// to a handler that calls setState on the unmounted component.
|
||||
@@ -57,7 +61,7 @@ export function ArtifactDragHandle({
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
function handlePointerDown(e: React.PointerEvent) {
|
||||
function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) {
|
||||
e.preventDefault();
|
||||
startXRef.current = e.clientX;
|
||||
|
||||
@@ -67,9 +71,31 @@ export function ArtifactDragHandle({
|
||||
) as HTMLElement | null;
|
||||
startWidthRef.current = panel?.offsetWidth ?? DEFAULT_PANEL_WIDTH;
|
||||
|
||||
// Capture the pointer so pointermove/pointerup still reach us when the
|
||||
// cursor drifts over sandboxed artifact iframes. Without this, the iframe
|
||||
// eats the events and the drag gets stuck (SECRT-2256).
|
||||
try {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
pointerIdRef.current = e.pointerId;
|
||||
} catch {
|
||||
// Non-supporting environments (older test DOMs) — safe to ignore.
|
||||
}
|
||||
|
||||
setIsDragging(true);
|
||||
}
|
||||
|
||||
function handlePointerUp(e: React.PointerEvent<HTMLDivElement>) {
|
||||
if (pointerIdRef.current != null) {
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture(pointerIdRef.current);
|
||||
} catch {
|
||||
// Capture may already be released.
|
||||
}
|
||||
pointerIdRef.current = null;
|
||||
}
|
||||
setIsDragging(false);
|
||||
}
|
||||
|
||||
return (
|
||||
// 12px transparent hit target with the visible 1px line centered inside
|
||||
// (WCAG-compliant, matches ~8-12px conventions of other resizable panels).
|
||||
@@ -81,6 +107,9 @@ export function ArtifactDragHandle({
|
||||
"group absolute -left-1.5 top-0 z-10 flex h-full w-3 cursor-col-resize items-stretch justify-center",
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
style={{ touchAction: "none" }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
Reference in New Issue
Block a user