Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 5e058aa

Browse files
authored
fix(site/src/pages/AgentsPage): end resize drags on pointercancel and lostpointercapture (#29156)
If a resize drag on the right shelf is interrupted (window loses focus, or a secondary button opens a context menu mid-drag), the shelf stays hidden and the top-bar toggle does nothing until the user navigates away and back. `RightPanel` previews the drag result through a `dragSnap` override that only `pointerup` cleared. Chromium ends an interrupted drag with `lostpointercapture` and no `pointerup`, so the override stayed pinned at `"closed"` and the panel ignored `isOpen` until the chat view remounted. `pointerup`, `pointercancel` and `lostpointercapture` now share one finalisation path. A release commits the snap; the other two clear the override, reset the parent's live-expanded state and undo a sidebar collapse the drag caused, without committing. The snap is mirrored in a ref so a release that lands before the last `pointermove` renders commits the right zone, `pointerdown` ignores non-primary pointers, secondary buttons and re-entry, and the handle gets `touch-action: none`. Behaviour is covered in `RightPanel.test.tsx` through a harness that owns the surrounding state and asserts callback payloads and the persisted width; 7 of 12 tests fail with the fix reverted. No stories were added: every end state after a drag renders identically to the existing `Default`, `Closed` or `Expanded` story. The real trigger was reproduced in headed Chromium under Xvfb with X11 input. The left sidebar handle in `ResizableChatsSidebarFrame` gets the same hardening (`lostpointercapture`, pointer guards) with 6 tests in `ResizableChatsSidebarFrame.test.tsx`; it had no visible bug since it has no snap states, but it stayed in drag mode after an interrupted drag. The handle keeps a `data-testid` rather than `role="separator"`: Biome rejects a non-focusable separator, and a focusable one without keyboard resizing would be a dead tab stop. --- Generated by Coder Agents on behalf of @jscottmiller.
1 parent 7a2cedf commit 5e058aa

5 files changed

Lines changed: 492 additions & 15 deletions

File tree

site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -564,10 +564,13 @@ export const ResizableSidebar: Story = {
564564

565565
const sidebarWidth = () =>
566566
sidebar.style.getPropertyValue("--agents-left-sidebar-width");
567+
// Synthetic pointer events default isPrimary to false; a real mouse
568+
// always reports true, and the handle ignores non-primary pointers.
569+
const pointer = { pointerId: 1, isPrimary: true };
567570
const dragSidebar = (fromX: number, toX: number) => {
568-
fireEvent.pointerDown(handle, { clientX: fromX, pointerId: 1 });
569-
fireEvent.pointerMove(handle, { clientX: toX, pointerId: 1 });
570-
fireEvent.pointerUp(handle, { clientX: toX, pointerId: 1 });
571+
fireEvent.pointerDown(handle, { ...pointer, clientX: fromX });
572+
fireEvent.pointerMove(handle, { ...pointer, clientX: toX });
573+
fireEvent.pointerUp(handle, { ...pointer, clientX: toX });
571574
};
572575

573576
const initialWidth = clampLeftSidebarWidth(LEFT_SIDEBAR_DEFAULT_WIDTH);
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { fireEvent, render, screen } from "@testing-library/react";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
import { ResizableChatsSidebarFrame } from "./ResizableChatsSidebarFrame";
4+
import {
5+
LEFT_SIDEBAR_DEFAULT_WIDTH,
6+
LEFT_SIDEBAR_STORAGE_KEY,
7+
} from "./sidebarWidth";
8+
9+
// Pointer events are dispatched with fireEvent because userEvent cannot emit
10+
// lostpointercapture or pointercancel, set isPrimary, or drive a second
11+
// pointer ID.
12+
const PRIMARY = { pointerId: 1, button: 0, isPrimary: true };
13+
const SECONDARY_POINTER = { pointerId: 2, button: 0, isPrimary: false };
14+
const RIGHT_BUTTON = { pointerId: 1, button: 2, isPrimary: true };
15+
16+
const persistedWidth = () =>
17+
Number(localStorage.getItem(LEFT_SIDEBAR_STORAGE_KEY));
18+
19+
const renderHandle = () => {
20+
render(
21+
<ResizableChatsSidebarFrame>
22+
<div>sidebar</div>
23+
</ResizableChatsSidebarFrame>,
24+
);
25+
return screen.getByTestId("agents-sidebar-resize-handle");
26+
};
27+
28+
describe("ResizableChatsSidebarFrame", () => {
29+
beforeEach(() => {
30+
vi.stubGlobal("innerWidth", 1440);
31+
localStorage.clear();
32+
});
33+
34+
afterEach(() => {
35+
vi.unstubAllGlobals();
36+
});
37+
38+
it("persists the width while a primary drag is in progress", () => {
39+
const handle = renderHandle();
40+
41+
fireEvent.pointerDown(handle, { ...PRIMARY, clientX: 0 });
42+
fireEvent.pointerMove(handle, { ...PRIMARY, clientX: 40 });
43+
44+
expect(persistedWidth()).toBe(LEFT_SIDEBAR_DEFAULT_WIDTH + 40);
45+
});
46+
47+
it("stops tracking after lostpointercapture without pointerup", () => {
48+
const handle = renderHandle();
49+
50+
fireEvent.pointerDown(handle, { ...PRIMARY, clientX: 0 });
51+
fireEvent.pointerMove(handle, { ...PRIMARY, clientX: 40 });
52+
fireEvent.lostPointerCapture(handle, PRIMARY);
53+
fireEvent.pointerMove(handle, { ...PRIMARY, clientX: 200 });
54+
55+
expect(persistedWidth()).toBe(LEFT_SIDEBAR_DEFAULT_WIDTH + 40);
56+
});
57+
58+
it("stops tracking after pointercancel", () => {
59+
const handle = renderHandle();
60+
61+
fireEvent.pointerDown(handle, { ...PRIMARY, clientX: 0 });
62+
fireEvent.pointerMove(handle, { ...PRIMARY, clientX: 40 });
63+
fireEvent.pointerCancel(handle, PRIMARY);
64+
fireEvent.pointerMove(handle, { ...PRIMARY, clientX: 200 });
65+
66+
expect(persistedWidth()).toBe(LEFT_SIDEBAR_DEFAULT_WIDTH + 40);
67+
});
68+
69+
it("ignores a secondary-button pointerdown", () => {
70+
const handle = renderHandle();
71+
72+
fireEvent.pointerDown(handle, { ...RIGHT_BUTTON, clientX: 0 });
73+
fireEvent.pointerMove(handle, { ...RIGHT_BUTTON, clientX: 40 });
74+
75+
expect(localStorage.getItem(LEFT_SIDEBAR_STORAGE_KEY)).toBeNull();
76+
});
77+
78+
it("ignores a non-primary pointer", () => {
79+
const handle = renderHandle();
80+
81+
fireEvent.pointerDown(handle, { ...SECONDARY_POINTER, clientX: 0 });
82+
fireEvent.pointerMove(handle, { ...SECONDARY_POINTER, clientX: 40 });
83+
84+
expect(localStorage.getItem(LEFT_SIDEBAR_STORAGE_KEY)).toBeNull();
85+
});
86+
87+
it("ignores a second pointer while a drag is in progress", () => {
88+
const handle = renderHandle();
89+
90+
fireEvent.pointerDown(handle, { ...PRIMARY, clientX: 0 });
91+
fireEvent.pointerDown(handle, { ...SECONDARY_POINTER, clientX: 0 });
92+
fireEvent.pointerMove(handle, { ...SECONDARY_POINTER, clientX: 200 });
93+
fireEvent.pointerUp(handle, SECONDARY_POINTER);
94+
fireEvent.pointerMove(handle, { ...PRIMARY, clientX: 40 });
95+
96+
expect(persistedWidth()).toBe(LEFT_SIDEBAR_DEFAULT_WIDTH + 40);
97+
});
98+
});

site/src/pages/AgentsPage/components/ChatsSidebar/ResizableChatsSidebarFrame.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const ResizableChatsSidebarFrame = ({
2929
const [width, setWidth] = useState(loadPersistedLeftSidebarWidth);
3030
const maxWidth = getLeftSidebarMaxWidth();
3131
const isDragging = useRef(false);
32+
const activePointerId = useRef<number | null>(null);
3233
const startX = useRef(0);
3334
const startWidth = useRef(0);
3435

@@ -54,28 +55,39 @@ export const ResizableChatsSidebarFrame = ({
5455
}, []);
5556

5657
const handlePointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
58+
// Only a primary left-button pointer starts a drag; a second pointer
59+
// cannot take over one that is already in progress.
60+
if (isDragging.current || e.button !== 0 || !e.isPrimary) {
61+
return;
62+
}
5763
e.preventDefault();
5864
isDragging.current = true;
65+
activePointerId.current = e.pointerId;
5966
startX.current = e.clientX;
6067
startWidth.current = width;
6168
e.currentTarget.setPointerCapture?.(e.pointerId);
6269
};
6370

6471
const handlePointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
65-
if (!isDragging.current) {
72+
if (!isDragging.current || e.pointerId !== activePointerId.current) {
6673
return;
6774
}
6875

6976
const rawWidth = startWidth.current + (e.clientX - startX.current);
7077
setUserWidth(rawWidth);
7178
};
7279

80+
// Ends the drag on pointerup, pointercancel, and lostpointercapture. The
81+
// last two fire without pointerup when the browser claims the gesture or
82+
// capture is lost (window deactivation, context menu), so all three must
83+
// reset the drag state.
7384
const handlePointerEnd = (e: ReactPointerEvent<HTMLDivElement>) => {
74-
if (!isDragging.current) {
85+
if (!isDragging.current || e.pointerId !== activePointerId.current) {
7586
return;
7687
}
7788

7889
isDragging.current = false;
90+
activePointerId.current = null;
7991
if (e.currentTarget.hasPointerCapture?.(e.pointerId)) {
8092
e.currentTarget.releasePointerCapture?.(e.pointerId);
8193
}
@@ -129,6 +141,7 @@ export const ResizableChatsSidebarFrame = ({
129141
onPointerMove={handlePointerMove}
130142
onPointerUp={handlePointerEnd}
131143
onPointerCancel={handlePointerEnd}
144+
onLostPointerCapture={handlePointerEnd}
132145
onKeyDown={handleKeyDown}
133146
className="absolute top-0 right-0 z-20 hidden h-full w-1 touch-none cursor-col-resize select-none transition-colors hover:bg-content-link focus-visible:bg-content-link focus-visible:outline-hidden sm:block"
134147
/>

0 commit comments

Comments
 (0)