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:
Nicholas Tindle
2026-04-17 16:16:02 -05:00
parent 3a01874911
commit 6287bb29ff
2 changed files with 167 additions and 1 deletions

View File

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

View File

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