From a1ad1dad056234d9aaabbadf3270864ad7a4fc27 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Wed, 9 Sep 2026 23:24:40 +0000 Subject: [PATCH 1/5] refactor(site/src/pages/AgentsPage/components/RightPanel): extract drag finalisation Move the pointerup teardown into finishDrag so other terminal pointer events can share it. Use currentTarget instead of casting target and release capture only while it is held. No behaviour change. --- .../components/RightPanel/RightPanel.tsx | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/site/src/pages/AgentsPage/components/RightPanel/RightPanel.tsx b/site/src/pages/AgentsPage/components/RightPanel/RightPanel.tsx index 15235e3e8e8d1..4e1dd3631a099 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/RightPanel.tsx +++ b/site/src/pages/AgentsPage/components/RightPanel/RightPanel.tsx @@ -112,11 +112,9 @@ function useResizableDrag({ setDragSnap(null); sidebarCollapsedByDrag.current = false; startX.current = e.clientX; - const panel = (e.target as HTMLElement).closest( - "[data-testid='agents-right-panel']", - ); + const panel = e.currentTarget.closest("[data-testid='agents-right-panel']"); startWidth.current = panel?.getBoundingClientRect().width ?? width; - (e.target as HTMLElement).setPointerCapture(e.pointerId); + e.currentTarget.setPointerCapture(e.pointerId); }; const handlePointerMove = (e: ReactPointerEvent) => { @@ -160,24 +158,33 @@ function useResizableDrag({ onVisualExpandedChange?.(nextVisualExpanded); }; - const handlePointerUp = (e: ReactPointerEvent) => { + const finishDrag = ( + e: ReactPointerEvent, + { commit }: { commit: boolean }, + ) => { if (!isDragging.current) { return; } const snap = dragSnap; isDragging.current = false; setDragSnap(null); - (e.target as HTMLElement).releasePointerCapture(e.pointerId); + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } // Clear the drag override so parent falls back to its // own committed expanded state. onVisualExpandedChange?.(null); - if (snap) { + if (commit && snap) { onSnapCommit(snap); } }; + const handlePointerUp = (e: ReactPointerEvent) => { + finishDrag(e, { commit: true }); + }; + // Derive visual state: during a drag the snap overrides the // committed parent state so the panel reacts live. const visualExpanded = From ea8537de17325372044b518576ccbed198f27158 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Wed, 9 Sep 2026 23:25:43 +0000 Subject: [PATCH 2/5] fix(site/src/pages/AgentsPage/components/RightPanel): end drags on pointercancel and lostpointercapture When Chromium releases mouse capture mid-drag (window deactivated, native context menu) it fires lostpointercapture with no pointerup. The drag override that hides or expands the panel during a drag was only cleared on pointerup, so it stayed pinned: the panel remained display:none and the top-bar toggle had no visible effect until the chat view remounted. Handle pointercancel and lostpointercapture as an abort that clears the override, notifies the parent, and undoes a drag-induced sidebar collapse without committing the snap. Read the snap from a ref so a pointerup landing before the last pointermove renders commits the zone the pointer actually ended in. Ignore non-primary and non-left-button pointerdowns, check the pointer id on move and end, and set touch-action: none on the handle. --- .../components/RightPanel/RightPanel.tsx | 47 +++++++++++++++++-- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/site/src/pages/AgentsPage/components/RightPanel/RightPanel.tsx b/site/src/pages/AgentsPage/components/RightPanel/RightPanel.tsx index 4e1dd3631a099..6af28f1c13033 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/RightPanel.tsx +++ b/site/src/pages/AgentsPage/components/RightPanel/RightPanel.tsx @@ -97,18 +97,28 @@ function useResizableDrag({ getPanelMaxWidth: () => number; }) { const isDragging = useRef(false); + const activePointerId = useRef(null); const startX = useRef(0); const startWidth = useRef(0); const sidebarCollapsedByDrag = useRef(false); // Track snap state during a drag. This is state (not a ref) so // the panel visually updates as the user drags across thresholds. + // The ref mirrors it for the terminal handlers: a pointerup can + // arrive before the state update from the last pointermove has + // rendered, and the commit must use the zone the pointer ended in. const [dragSnap, setDragSnap] = useState< "normal" | "expanded" | "closed" | null >(null); + const snapRef = useRef<"normal" | "expanded" | "closed" | null>(null); const handlePointerDown = (e: ReactPointerEvent) => { + if (isDragging.current || e.button !== 0 || !e.isPrimary) { + return; + } e.preventDefault(); isDragging.current = true; + activePointerId.current = e.pointerId; + snapRef.current = null; setDragSnap(null); sidebarCollapsedByDrag.current = false; startX.current = e.clientX; @@ -118,7 +128,7 @@ function useResizableDrag({ }; const handlePointerMove = (e: ReactPointerEvent) => { - if (!isDragging.current) { + if (!isDragging.current || e.pointerId !== activePointerId.current) { return; } const delta = startX.current - e.clientX; @@ -148,6 +158,7 @@ function useResizableDrag({ nextSnap = "normal"; setWidth(Math.min(maxWidth, Math.max(MIN_WIDTH, raw))); } + snapRef.current = nextSnap; setDragSnap(nextSnap); // Notify parent of the live visual expanded state so @@ -158,15 +169,22 @@ function useResizableDrag({ onVisualExpandedChange?.(nextVisualExpanded); }; + // Ends the drag for the active pointer. A pointerup commits the snap + // the pointer ended in; pointercancel and lostpointercapture clear the + // drag override without committing and keep the live width. A normal + // release also fires lostpointercapture, which the isDragging guard + // turns into a no-op. const finishDrag = ( e: ReactPointerEvent, { commit }: { commit: boolean }, ) => { - if (!isDragging.current) { + if (!isDragging.current || e.pointerId !== activePointerId.current) { return; } - const snap = dragSnap; + const snap = snapRef.current; isDragging.current = false; + activePointerId.current = null; + snapRef.current = null; setDragSnap(null); if (e.currentTarget.hasPointerCapture(e.pointerId)) { e.currentTarget.releasePointerCapture(e.pointerId); @@ -176,7 +194,18 @@ function useResizableDrag({ // own committed expanded state. onVisualExpandedChange?.(null); - if (commit && snap) { + if (!commit) { + if ( + sidebarCollapsedByDrag.current && + isSidebarCollapsed && + onToggleSidebarCollapsed + ) { + onToggleSidebarCollapsed(); + } + sidebarCollapsedByDrag.current = false; + return; + } + if (snap) { onSnapCommit(snap); } }; @@ -185,6 +214,10 @@ function useResizableDrag({ finishDrag(e, { commit: true }); }; + const handlePointerAbort = (e: ReactPointerEvent) => { + finishDrag(e, { commit: false }); + }; + // Derive visual state: during a drag the snap overrides the // committed parent state so the panel reacts live. const visualExpanded = @@ -200,6 +233,7 @@ function useResizableDrag({ handlePointerDown, handlePointerMove, handlePointerUp, + handlePointerAbort, }; } @@ -257,6 +291,7 @@ export const RightPanel = ({ handlePointerDown, handlePointerMove, handlePointerUp, + handlePointerAbort, } = useResizableDrag({ isExpanded, width, @@ -350,8 +385,10 @@ export const RightPanel = ({ onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} + onPointerCancel={handlePointerAbort} + onLostPointerCapture={handlePointerAbort} className={cn( - "absolute top-0 left-0 z-20 hidden h-full w-1 cursor-col-resize select-none transition-colors hover:bg-content-link lg:block", + "absolute top-0 left-0 z-20 hidden h-full w-1 touch-none cursor-col-resize select-none transition-colors hover:bg-content-link lg:block", visualExpanded && "-left-1", )} /> From 5aaa4f8173d859157de28ec1fe41139524a38332 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Wed, 9 Sep 2026 23:30:21 +0000 Subject: [PATCH 3/5] test(site/src/pages/AgentsPage/components/RightPanel): cover interrupted resize drags Add a test id to the drag handle and Vitest coverage that drives drags ending in lostpointercapture, pointercancel or pointerup through a harness owning the open, expanded and sidebar-collapse state, asserting the resulting state callbacks and persisted width. --- .../components/RightPanel/RightPanel.test.tsx | 318 ++++++++++++++++++ .../components/RightPanel/RightPanel.tsx | 1 + 2 files changed, 319 insertions(+) create mode 100644 site/src/pages/AgentsPage/components/RightPanel/RightPanel.test.tsx diff --git a/site/src/pages/AgentsPage/components/RightPanel/RightPanel.test.tsx b/site/src/pages/AgentsPage/components/RightPanel/RightPanel.test.tsx new file mode 100644 index 0000000000000..d730bc80bd319 --- /dev/null +++ b/site/src/pages/AgentsPage/components/RightPanel/RightPanel.test.tsx @@ -0,0 +1,318 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { type FC, useState } from "react"; +import { MemoryRouter, Outlet, Route, Routes } from "react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentsPageOutletContext } from "../../AgentsPageLayout"; +import { RIGHT_PANEL_WIDTH_KEY, RightPanel } from "./RightPanel"; + +interface HarnessProps { + onOpenChange?: (isOpen: boolean) => void; + onExpandedChange?: (isExpanded: boolean) => void; + onVisualExpandedChange?: (visualExpanded: boolean | null) => void; +} + +/** + * Owns the open and expanded state around a RightPanel the way the chat + * page does and reports every transition so tests can assert on them. + */ +const RightPanelHarness: FC = ({ + onOpenChange, + onExpandedChange, + onVisualExpandedChange, +}) => { + const [isOpen, setIsOpenState] = useState(true); + const [isExpanded, setIsExpandedState] = useState(false); + const setIsOpen = (next: boolean) => { + setIsOpenState(next); + onOpenChange?.(next); + }; + const setIsExpanded = (next: boolean) => { + setIsExpandedState(next); + onExpandedChange?.(next); + }; + + return ( + setIsExpanded(!isExpanded)} + onClose={() => setIsOpen(false)} + onVisualExpandedChange={onVisualExpandedChange} + > +
Panel content
+
+ ); +}; + +interface SidebarHarnessProps extends HarnessProps { + onSidebarCollapsedChange?: (isCollapsed: boolean) => void; +} + +/** + * Supplies the outlet context the panel uses to collapse the chats + * sidebar while the pointer is at the left edge of the viewport. + */ +const RightPanelWithSidebarHarness: FC = ({ + onSidebarCollapsedChange, + ...harnessProps +}) => { + const [isSidebarCollapsed, setIsSidebarCollapsedState] = useState(false); + const setIsSidebarCollapsed = (next: boolean) => { + setIsSidebarCollapsedState(next); + onSidebarCollapsedChange?.(next); + }; + const outletContext: AgentsPageOutletContext = { + chatErrorReasons: {}, + setChatErrorReason: () => {}, + clearChatErrorReason: () => {}, + requestArchiveAgent: () => {}, + requestUnarchiveAgent: () => {}, + requestArchiveAndDeleteWorkspace: () => {}, + requestPinAgent: () => {}, + requestUnpinAgent: () => {}, + isArchiving: false, + archivingChatId: undefined, + activeChatChildren: undefined, + isSidebarCollapsed, + onToggleSidebarCollapsed: () => setIsSidebarCollapsed(!isSidebarCollapsed), + onExpandSidebar: () => setIsSidebarCollapsed(false), + onChatReady: () => {}, + }; + + return ( + + }> + } /> + + + ); +}; + +// jsdom lays nothing out: the panel's rect is 0 wide and its parent has +// no client width. The viewport is pinned below the side-by-side +// breakpoint so the max width comes from innerWidth alone (700px) and +// the initial 480px width is not clamped on mount. With a zero start +// width the raw drag width is -clientX, giving these zones: +const CLOSE_ZONE_X = 100; // raw -100 < 280 +const NORMAL_ZONE_X = -600; // raw 600, kept as the live width +const EXPAND_ZONE_X = -900; // raw 900 > 700 + 80 +const SIDEBAR_EDGE_X = 10; // below the 80px left-edge threshold + +// Pointer events are dispatched with fireEvent: user-event cannot emit +// lostpointercapture or pointercancel, set isPrimary, or drive a second +// pointer id, and the capture methods are stubbed globally for jsdom. +const pointer = { pointerId: 1, button: 0, isPrimary: true }; + +const persistedWidth = () => localStorage.getItem(RIGHT_PANEL_WIDTH_KEY); + +const getHandle = () => screen.getByTestId("agents-right-panel-resize-handle"); + +const pointerDown = (init: Partial = {}) => + fireEvent.pointerDown(getHandle(), { ...pointer, clientX: 0, ...init }); +const pointerMove = (clientX: number, init: Partial = {}) => + fireEvent.pointerMove(getHandle(), { ...pointer, clientX, ...init }); +const pointerUp = (clientX: number, init: Partial = {}) => + fireEvent.pointerUp(getHandle(), { ...pointer, clientX, ...init }); +const pointerCancel = (clientX: number) => + fireEvent.pointerCancel(getHandle(), { ...pointer, clientX }); +const lostPointerCapture = (clientX: number) => + fireEvent.lostPointerCapture(getHandle(), { ...pointer, clientX }); + +const renderHarness = () => { + const onOpenChange = vi.fn(); + const onExpandedChange = vi.fn(); + const onVisualExpandedChange = vi.fn(); + render( + + + , + ); + return { onOpenChange, onExpandedChange, onVisualExpandedChange }; +}; + +beforeEach(() => { + localStorage.removeItem(RIGHT_PANEL_WIDTH_KEY); + vi.stubGlobal("innerWidth", 1000); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + localStorage.removeItem(RIGHT_PANEL_WIDTH_KEY); +}); + +describe("RightPanel resize drag", () => { + describe("a drag that ends without pointerup", () => { + // The abort must clear the visual override and leave the open and + // expanded state to their owners, without committing the snap. + it("clears the live-expanded override and does not close the panel", () => { + const { onOpenChange, onVisualExpandedChange } = renderHarness(); + + pointerDown(); + pointerMove(NORMAL_ZONE_X); + pointerMove(CLOSE_ZONE_X); + expect(onVisualExpandedChange).toHaveBeenLastCalledWith(false); + + lostPointerCapture(CLOSE_ZONE_X); + + expect(onVisualExpandedChange).toHaveBeenLastCalledWith(null); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it("keeps the live width instead of resetting it", () => { + renderHarness(); + + pointerDown(); + pointerMove(NORMAL_ZONE_X); + pointerMove(CLOSE_ZONE_X); + lostPointerCapture(CLOSE_ZONE_X); + + expect(persistedWidth()).toBe("600"); + }); + + it("handles pointercancel the same way", () => { + const { onOpenChange, onVisualExpandedChange } = renderHarness(); + + pointerDown(); + pointerMove(NORMAL_ZONE_X); + pointerCancel(NORMAL_ZONE_X); + + expect(onVisualExpandedChange).toHaveBeenLastCalledWith(null); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(persistedWidth()).toBe("600"); + }); + + it("does not commit an expanded snap", () => { + const { onExpandedChange, onVisualExpandedChange } = renderHarness(); + + pointerDown(); + pointerMove(EXPAND_ZONE_X); + expect(onVisualExpandedChange).toHaveBeenLastCalledWith(true); + + lostPointerCapture(EXPAND_ZONE_X); + + expect(onVisualExpandedChange).toHaveBeenLastCalledWith(null); + expect(onExpandedChange).not.toHaveBeenCalled(); + }); + + it("re-expands a sidebar the same drag collapsed", () => { + const onSidebarCollapsedChange = vi.fn(); + render( + + + , + ); + + pointerDown(); + pointerMove(SIDEBAR_EDGE_X); + expect(onSidebarCollapsedChange).toHaveBeenLastCalledWith(true); + + lostPointerCapture(SIDEBAR_EDGE_X); + + expect(onSidebarCollapsedChange).toHaveBeenLastCalledWith(false); + }); + }); + + describe("a drag that ends with pointerup", () => { + it("commits the close and resets the width", () => { + const { onOpenChange, onVisualExpandedChange } = renderHarness(); + + pointerDown(); + pointerMove(NORMAL_ZONE_X); + pointerMove(CLOSE_ZONE_X); + pointerUp(CLOSE_ZONE_X); + + expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false); + expect(onVisualExpandedChange).toHaveBeenLastCalledWith(null); + expect(persistedWidth()).toBe("480"); + }); + + it("ignores the lostpointercapture that follows the release", () => { + const { onOpenChange } = renderHarness(); + + pointerDown(); + pointerMove(CLOSE_ZONE_X); + pointerUp(CLOSE_ZONE_X); + lostPointerCapture(CLOSE_ZONE_X); + + expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false); + }); + + it("commits the expanded snap", () => { + const { onExpandedChange } = renderHarness(); + + pointerDown(); + pointerMove(EXPAND_ZONE_X); + pointerUp(EXPAND_ZONE_X); + + expect(onExpandedChange).toHaveBeenCalledExactlyOnceWith(true); + }); + + it("keeps a sidebar collapse the drag caused", () => { + const onSidebarCollapsedChange = vi.fn(); + render( + + + , + ); + + pointerDown(); + pointerMove(SIDEBAR_EDGE_X); + pointerUp(SIDEBAR_EDGE_X); + + expect(onSidebarCollapsedChange).toHaveBeenCalledExactlyOnceWith(true); + }); + }); + + describe("pointerdown guards", () => { + // A press that must not start a drag leaves the release below with + // nothing to commit. + it("ignores a secondary-button press", () => { + const { onOpenChange, onVisualExpandedChange } = renderHarness(); + + pointerDown({ button: 2 }); + pointerMove(CLOSE_ZONE_X); + pointerUp(CLOSE_ZONE_X, { button: 2 }); + + expect(onOpenChange).not.toHaveBeenCalled(); + expect(onVisualExpandedChange).not.toHaveBeenCalled(); + expect(persistedWidth()).toBe("480"); + }); + + it("ignores a non-primary pointer", () => { + const { onOpenChange, onVisualExpandedChange } = renderHarness(); + + pointerDown({ isPrimary: false }); + pointerMove(CLOSE_ZONE_X); + pointerUp(CLOSE_ZONE_X); + + expect(onOpenChange).not.toHaveBeenCalled(); + expect(onVisualExpandedChange).not.toHaveBeenCalled(); + }); + + it("ignores a second pointer while a drag is active", () => { + const { onOpenChange, onVisualExpandedChange } = renderHarness(); + + pointerDown(); + pointerMove(NORMAL_ZONE_X); + pointerDown({ pointerId: 2, clientX: 500 }); + pointerMove(CLOSE_ZONE_X, { pointerId: 2 }); + pointerUp(CLOSE_ZONE_X, { pointerId: 2 }); + + expect(onOpenChange).not.toHaveBeenCalled(); + expect(onVisualExpandedChange).toHaveBeenLastCalledWith(false); + + pointerUp(NORMAL_ZONE_X); + + expect(onVisualExpandedChange).toHaveBeenLastCalledWith(null); + expect(persistedWidth()).toBe("600"); + }); + }); +}); diff --git a/site/src/pages/AgentsPage/components/RightPanel/RightPanel.tsx b/site/src/pages/AgentsPage/components/RightPanel/RightPanel.tsx index 6af28f1c13033..1aaf3765d7d69 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/RightPanel.tsx +++ b/site/src/pages/AgentsPage/components/RightPanel/RightPanel.tsx @@ -382,6 +382,7 @@ export const RightPanel = ({ > {/* Drag handle (sm+, on the left edge of the panel) */}
Date: Thu, 10 Sep 2026 19:04:51 +0000 Subject: [PATCH 4/5] fix(site/src/pages/AgentsPage/components/ChatsSidebar): end drag on lostpointercapture Mirror the right panel drag hardening in the left sidebar resize handle: end the drag on lostpointercapture, and only let a primary left-button pointer start or end a drag. --- .../ResizableChatsSidebarFrame.test.tsx | 98 +++++++++++++++++++ .../ResizableChatsSidebarFrame.tsx | 17 +++- 2 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 site/src/pages/AgentsPage/components/ChatsSidebar/ResizableChatsSidebarFrame.test.tsx diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/ResizableChatsSidebarFrame.test.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ResizableChatsSidebarFrame.test.tsx new file mode 100644 index 0000000000000..7795ca5656c95 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ResizableChatsSidebarFrame.test.tsx @@ -0,0 +1,98 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ResizableChatsSidebarFrame } from "./ResizableChatsSidebarFrame"; +import { + LEFT_SIDEBAR_DEFAULT_WIDTH, + LEFT_SIDEBAR_STORAGE_KEY, +} from "./sidebarWidth"; + +// Pointer events are dispatched with fireEvent because userEvent cannot emit +// lostpointercapture or pointercancel, set isPrimary, or drive a second +// pointer ID. +const PRIMARY = { pointerId: 1, button: 0, isPrimary: true }; +const SECONDARY_POINTER = { pointerId: 2, button: 0, isPrimary: false }; +const RIGHT_BUTTON = { pointerId: 1, button: 2, isPrimary: true }; + +const persistedWidth = () => + Number(localStorage.getItem(LEFT_SIDEBAR_STORAGE_KEY)); + +const renderHandle = () => { + render( + +
sidebar
+
, + ); + return screen.getByTestId("agents-sidebar-resize-handle"); +}; + +describe("ResizableChatsSidebarFrame", () => { + beforeEach(() => { + vi.stubGlobal("innerWidth", 1440); + localStorage.clear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("persists the width while a primary drag is in progress", () => { + const handle = renderHandle(); + + fireEvent.pointerDown(handle, { ...PRIMARY, clientX: 0 }); + fireEvent.pointerMove(handle, { ...PRIMARY, clientX: 40 }); + + expect(persistedWidth()).toBe(LEFT_SIDEBAR_DEFAULT_WIDTH + 40); + }); + + it("stops tracking after lostpointercapture without pointerup", () => { + const handle = renderHandle(); + + fireEvent.pointerDown(handle, { ...PRIMARY, clientX: 0 }); + fireEvent.pointerMove(handle, { ...PRIMARY, clientX: 40 }); + fireEvent.lostPointerCapture(handle, PRIMARY); + fireEvent.pointerMove(handle, { ...PRIMARY, clientX: 200 }); + + expect(persistedWidth()).toBe(LEFT_SIDEBAR_DEFAULT_WIDTH + 40); + }); + + it("stops tracking after pointercancel", () => { + const handle = renderHandle(); + + fireEvent.pointerDown(handle, { ...PRIMARY, clientX: 0 }); + fireEvent.pointerMove(handle, { ...PRIMARY, clientX: 40 }); + fireEvent.pointerCancel(handle, PRIMARY); + fireEvent.pointerMove(handle, { ...PRIMARY, clientX: 200 }); + + expect(persistedWidth()).toBe(LEFT_SIDEBAR_DEFAULT_WIDTH + 40); + }); + + it("ignores a secondary-button pointerdown", () => { + const handle = renderHandle(); + + fireEvent.pointerDown(handle, { ...RIGHT_BUTTON, clientX: 0 }); + fireEvent.pointerMove(handle, { ...RIGHT_BUTTON, clientX: 40 }); + + expect(localStorage.getItem(LEFT_SIDEBAR_STORAGE_KEY)).toBeNull(); + }); + + it("ignores a non-primary pointer", () => { + const handle = renderHandle(); + + fireEvent.pointerDown(handle, { ...SECONDARY_POINTER, clientX: 0 }); + fireEvent.pointerMove(handle, { ...SECONDARY_POINTER, clientX: 40 }); + + expect(localStorage.getItem(LEFT_SIDEBAR_STORAGE_KEY)).toBeNull(); + }); + + it("ignores a second pointer while a drag is in progress", () => { + const handle = renderHandle(); + + fireEvent.pointerDown(handle, { ...PRIMARY, clientX: 0 }); + fireEvent.pointerDown(handle, { ...SECONDARY_POINTER, clientX: 0 }); + fireEvent.pointerMove(handle, { ...SECONDARY_POINTER, clientX: 200 }); + fireEvent.pointerUp(handle, SECONDARY_POINTER); + fireEvent.pointerMove(handle, { ...PRIMARY, clientX: 40 }); + + expect(persistedWidth()).toBe(LEFT_SIDEBAR_DEFAULT_WIDTH + 40); + }); +}); diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/ResizableChatsSidebarFrame.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ResizableChatsSidebarFrame.tsx index 111d1016a8623..dd97d7fb7686e 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/ResizableChatsSidebarFrame.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ResizableChatsSidebarFrame.tsx @@ -29,6 +29,7 @@ export const ResizableChatsSidebarFrame = ({ const [width, setWidth] = useState(loadPersistedLeftSidebarWidth); const maxWidth = getLeftSidebarMaxWidth(); const isDragging = useRef(false); + const activePointerId = useRef(null); const startX = useRef(0); const startWidth = useRef(0); @@ -54,15 +55,21 @@ export const ResizableChatsSidebarFrame = ({ }, []); const handlePointerDown = (e: ReactPointerEvent) => { + // Only a primary left-button pointer starts a drag; a second pointer + // cannot take over one that is already in progress. + if (isDragging.current || e.button !== 0 || !e.isPrimary) { + return; + } e.preventDefault(); isDragging.current = true; + activePointerId.current = e.pointerId; startX.current = e.clientX; startWidth.current = width; e.currentTarget.setPointerCapture?.(e.pointerId); }; const handlePointerMove = (e: ReactPointerEvent) => { - if (!isDragging.current) { + if (!isDragging.current || e.pointerId !== activePointerId.current) { return; } @@ -70,12 +77,17 @@ export const ResizableChatsSidebarFrame = ({ setUserWidth(rawWidth); }; + // Ends the drag on pointerup, pointercancel, and lostpointercapture. The + // last two fire without pointerup when the browser claims the gesture or + // capture is lost (window deactivation, context menu), so all three must + // reset the drag state. const handlePointerEnd = (e: ReactPointerEvent) => { - if (!isDragging.current) { + if (!isDragging.current || e.pointerId !== activePointerId.current) { return; } isDragging.current = false; + activePointerId.current = null; if (e.currentTarget.hasPointerCapture?.(e.pointerId)) { e.currentTarget.releasePointerCapture?.(e.pointerId); } @@ -129,6 +141,7 @@ export const ResizableChatsSidebarFrame = ({ onPointerMove={handlePointerMove} onPointerUp={handlePointerEnd} onPointerCancel={handlePointerEnd} + onLostPointerCapture={handlePointerEnd} onKeyDown={handleKeyDown} 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" /> From a9a7fef62260769a69ca8ba8bfdad9ad0cfc3110 Mon Sep 17 00:00:00 2001 From: Scott Miller Date: Thu, 10 Sep 2026 19:42:19 +0000 Subject: [PATCH 5/5] test(site/src/pages/AgentsPage): mark synthetic sidebar drag pointer as primary The sidebar resize handle ignores non-primary pointers, and synthetic PointerEvent init defaults isPrimary to false. --- site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index 450a71b3cfe2e..bb19ff4630fc5 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx @@ -564,10 +564,13 @@ export const ResizableSidebar: Story = { const sidebarWidth = () => sidebar.style.getPropertyValue("--agents-left-sidebar-width"); + // Synthetic pointer events default isPrimary to false; a real mouse + // always reports true, and the handle ignores non-primary pointers. + const pointer = { pointerId: 1, isPrimary: true }; const dragSidebar = (fromX: number, toX: number) => { - fireEvent.pointerDown(handle, { clientX: fromX, pointerId: 1 }); - fireEvent.pointerMove(handle, { clientX: toX, pointerId: 1 }); - fireEvent.pointerUp(handle, { clientX: toX, pointerId: 1 }); + fireEvent.pointerDown(handle, { ...pointer, clientX: fromX }); + fireEvent.pointerMove(handle, { ...pointer, clientX: toX }); + fireEvent.pointerUp(handle, { ...pointer, clientX: toX }); }; const initialWidth = clampLeftSidebarWidth(LEFT_SIDEBAR_DEFAULT_WIDTH);