diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactDragHandle.test.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactDragHandle.test.tsx new file mode 100644 index 0000000000..e0e26b5e39 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactDragHandle.test.tsx @@ -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( +
+ +
, + ); + 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; + + 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(); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactDragHandle.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactDragHandle.tsx index 0f30ce2078..f169bec9e4 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactDragHandle.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ArtifactPanel/components/ArtifactDragHandle.tsx @@ -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(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) { 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) { + 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" }} >