From 975cd49c3f5ced2633c6cfe03079ac7b851d7db1 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:20:24 +0800 Subject: [PATCH] fix(resize): keep drag cursor consistent for table & chat resize (MUL-5166) (#5824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(resize): keep the drag cursor consistent for table and chat resize Table column resize and the chat floating-window resize locked the active cursor via document.body.style.cursor, which loses the CSS cascade to any descendant that declares its own cursor (clickable rows, inputs, buttons). So the resize cursor showed on hover but flipped back to pointer/text/default once the drag pointer swept over those elements — inconsistent with the sidebar rail and react-resizable-panels, which force it globally. Switch both to the same robust contract used by the sidebar/panels: set a data attribute on during the drag and let a global `html[...], html[...] * { cursor: ...; user-select: none } !important` rule in base.css win over every descendant. Chat keys the attribute by direction (left/top/corner) so col/row/nw-resize stay correct. MUL-5166 Co-authored-by: multica-agent Co-Authored-By: Claude Opus 4.8 * fix(chat): clean up chat resize cursor lock on every drag-end path The chat window resize started the drag with setPointerCapture but only removed data-chat-resizing on pointerup. A pointer-capture drag can end via pointercancel, lostpointercapture, or window blur without ever firing pointerup; since the attribute now drives a global `html[...] * { cursor; user-select } !important` rule, a missed cleanup would strand it and freeze the whole page in the resize cursor with text selection disabled. Mirror the sidebar rail: one idempotent finishDrag() wired to pointerup, pointercancel, lostpointercapture and blur, releasing pointer capture and removing every listener plus the attribute from a single path. Add a regression test covering each termination path. MUL-5166 Co-Authored-By: Claude Opus 4.8 Co-authored-by: multica-agent * fix(ui): release table resize cursor lock on window blur too Table column resize wires stopResize to pointerup/pointercancel but not to window blur. With the resize cursor now held by a global `html[data-table-resizing] * { cursor; user-select } !important` rule, an alt-tab mid-drag (button released while the window is blurred, so no pointerup arrives) would strand the attribute and freeze the page. stopResize is idempotent, so also wire it to blur — matching the sidebar rail and chat. MUL-5166 Co-Authored-By: Claude Opus 4.8 Co-authored-by: multica-agent --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: multica-agent --- packages/ui/components/ui/data-table.tsx | 14 ++- packages/ui/styles/base.css | 24 ++++ .../chat/components/use-chat-resize.test.tsx | 115 ++++++++++++++++++ .../views/chat/components/use-chat-resize.ts | 62 +++++++--- 4 files changed, 191 insertions(+), 24 deletions(-) create mode 100644 packages/views/chat/components/use-chat-resize.test.tsx diff --git a/packages/ui/components/ui/data-table.tsx b/packages/ui/components/ui/data-table.tsx index 17d7f222b..cba0ad6b4 100644 --- a/packages/ui/components/ui/data-table.tsx +++ b/packages/ui/components/ui/data-table.tsx @@ -126,27 +126,29 @@ export function DataTable({ setResizingColumnId(header.column.id); setColumnWidth(header, startWidth); - const originalCursor = document.body.style.cursor; - const originalUserSelect = document.body.style.userSelect; - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; + // Lock the resize cursor globally so it survives the pointer leaving the + // narrow handle; the matching rule lives in packages/ui/styles/base.css. + document.documentElement.setAttribute("data-table-resizing", "true"); const handlePointerMove = (pointerEvent: PointerEvent) => { setColumnWidth(header, startWidth + pointerEvent.clientX - startX); }; + // stopResize is idempotent; wire it to every drag-end path (including a + // window blur mid-drag) so the global resize-cursor lock can never strand. const stopResize = () => { window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", stopResize); window.removeEventListener("pointercancel", stopResize); - document.body.style.cursor = originalCursor; - document.body.style.userSelect = originalUserSelect; + window.removeEventListener("blur", stopResize); + document.documentElement.removeAttribute("data-table-resizing"); setResizingColumnId(null); }; window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", stopResize); window.addEventListener("pointercancel", stopResize); + window.addEventListener("blur", stopResize); }, [setColumnWidth], ); diff --git a/packages/ui/styles/base.css b/packages/ui/styles/base.css index 001cc7c35..b4eb528cc 100644 --- a/packages/ui/styles/base.css +++ b/packages/ui/styles/base.css @@ -246,6 +246,30 @@ html:has( user-select: none !important; } +html[data-table-resizing="true"], +html[data-table-resizing="true"] * { + cursor: col-resize !important; + user-select: none !important; +} + +html[data-chat-resizing="left"], +html[data-chat-resizing="left"] * { + cursor: col-resize !important; + user-select: none !important; +} + +html[data-chat-resizing="top"], +html[data-chat-resizing="top"] * { + cursor: row-resize !important; + user-select: none !important; +} + +html[data-chat-resizing="corner"], +html[data-chat-resizing="corner"] * { + cursor: nw-resize !important; + user-select: none !important; +} + [data-sidebar-resizing="true"] [data-slot="sidebar-gap"], [data-sidebar-resizing="true"] [data-slot="sidebar-container"] { transition: none !important; diff --git a/packages/views/chat/components/use-chat-resize.test.tsx b/packages/views/chat/components/use-chat-resize.test.tsx new file mode 100644 index 000000000..43613db0a --- /dev/null +++ b/packages/views/chat/components/use-chat-resize.test.tsx @@ -0,0 +1,115 @@ +import { fireEvent, render } from "@testing-library/react"; +import { useRef } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ChatResizeHandles } from "./chat-resize-handles"; +import { useChatResize } from "./use-chat-resize"; + +// useChatResize only needs the size fields and setters from the chat store. +vi.mock("@multica/core/chat", () => { + const state = { + chatWidth: 360, + chatHeight: 480, + isExpanded: false, + setChatSize: vi.fn(), + setExpanded: vi.fn(), + }; + const useChatStore = Object.assign( + (selector?: (s: typeof state) => unknown) => + selector ? selector(state) : state, + { getState: () => state }, + ); + return { CHAT_MIN_W: 320, CHAT_MIN_H: 400, useChatStore }; +}); + +function Harness() { + const ref = useRef(null); + const { startDrag } = useChatResize(ref); + return ( +
+ +
+ ); +} + +const POINTER_ID = 11; + +function startLeftDrag(container: HTMLElement) { + // The left edge handle is the one with the col-resize cursor. + const handle = container.querySelector(".cursor-col-resize")!; + const setPointerCapture = vi.fn(); + const releasePointerCapture = vi.fn(); + // jsdom elements don't implement the pointer-capture API. + handle.setPointerCapture = setPointerCapture; + handle.hasPointerCapture = vi.fn(() => true); + handle.releasePointerCapture = releasePointerCapture; + + fireEvent.pointerDown(handle, { + button: 0, + isPrimary: true, + pointerId: POINTER_ID, + clientX: 100, + clientY: 100, + }); + + return { handle, setPointerCapture, releasePointerCapture }; +} + +describe("chat resize cursor lock cleanup", () => { + beforeEach(() => { + document.documentElement.removeAttribute("data-chat-resizing"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + document.documentElement.removeAttribute("data-chat-resizing"); + }); + + it("locks the resize cursor on drag start and captures the pointer", () => { + const { container } = render(); + const { setPointerCapture } = startLeftDrag(container); + + expect(setPointerCapture).toHaveBeenCalledWith(POINTER_ID); + expect(document.documentElement).toHaveAttribute( + "data-chat-resizing", + "left", + ); + }); + + it("clears the lock and releases capture on pointerup", () => { + const { container } = render(); + const { releasePointerCapture } = startLeftDrag(container); + + fireEvent.pointerUp(document, { pointerId: POINTER_ID }); + + expect(document.documentElement).not.toHaveAttribute("data-chat-resizing"); + expect(releasePointerCapture).toHaveBeenCalledWith(POINTER_ID); + }); + + it("clears the lock on pointercancel (no pointerup)", () => { + const { container } = render(); + startLeftDrag(container); + + fireEvent.pointerCancel(document, { pointerId: POINTER_ID }); + + expect(document.documentElement).not.toHaveAttribute("data-chat-resizing"); + }); + + it("clears the lock on lostpointercapture (no pointerup)", () => { + const { container } = render(); + const { handle } = startLeftDrag(container); + + fireEvent.lostPointerCapture(handle, { pointerId: POINTER_ID }); + + expect(document.documentElement).not.toHaveAttribute("data-chat-resizing"); + }); + + it("clears the lock when the window loses focus mid-drag", () => { + const { container } = render(); + startLeftDrag(container); + + fireEvent.blur(window); + + expect(document.documentElement).not.toHaveAttribute("data-chat-resizing"); + }); +}); diff --git a/packages/views/chat/components/use-chat-resize.ts b/packages/views/chat/components/use-chat-resize.ts index 01a53ddeb..5cf2c12e3 100644 --- a/packages/views/chat/components/use-chat-resize.ts +++ b/packages/views/chat/components/use-chat-resize.ts @@ -84,7 +84,9 @@ export function useChatResize( const startDrag = useCallback( (e: React.PointerEvent, dir: DragDir) => { e.preventDefault(); - (e.target as HTMLElement).setPointerCapture(e.pointerId); + const captureEl = e.currentTarget as HTMLElement; + const { pointerId } = e; + captureEl.setPointerCapture(pointerId); dragRef.current = { startX: e.clientX, @@ -95,9 +97,38 @@ export function useChatResize( }; setIsDragging(true); + // Lock the resize cursor globally so it survives the pointer leaving the + // narrow handle; per-direction rules live in packages/ui/styles/base.css. + document.documentElement.setAttribute("data-chat-resizing", dir); + + // One idempotent cleanup wired to every way a pointer-capture drag can + // end. A drag started with setPointerCapture may terminate via + // pointercancel / lostpointercapture / window blur without a pointerup; + // missing any of those would strand data-chat-resizing and freeze the + // whole page in the resize cursor with text selection disabled. + let finished = false; + const finishDrag = () => { + if (finished) return; + finished = true; + + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerup", onPointerUp); + document.removeEventListener("pointercancel", onPointerCancel); + captureEl.removeEventListener("lostpointercapture", onLostPointerCapture); + window.removeEventListener("blur", finishDrag); + + dragRef.current = null; + setIsDragging(false); + document.documentElement.removeAttribute("data-chat-resizing"); + + if (captureEl.hasPointerCapture?.(pointerId)) { + captureEl.releasePointerCapture?.(pointerId); + } + }; + const onPointerMove = (ev: PointerEvent) => { const d = dragRef.current; - if (!d) return; + if (!d || ev.pointerId !== pointerId) return; const { maxW: mw, maxH: mh } = boundsRef.current; @@ -112,26 +143,21 @@ export function useChatResize( setChatSize(clamp(rawW, CHAT_MIN_W, mw), clamp(rawH, CHAT_MIN_H, mh)); }; - - const onPointerUp = () => { - dragRef.current = null; - setIsDragging(false); - document.removeEventListener("pointermove", onPointerMove); - document.removeEventListener("pointerup", onPointerUp); - document.body.style.cursor = ""; - document.body.style.userSelect = ""; + const onPointerUp = (ev: PointerEvent) => { + if (ev.pointerId === pointerId) finishDrag(); + }; + const onPointerCancel = (ev: PointerEvent) => { + if (ev.pointerId === pointerId) finishDrag(); + }; + const onLostPointerCapture = (ev: PointerEvent) => { + if (ev.pointerId === pointerId) finishDrag(); }; document.addEventListener("pointermove", onPointerMove); document.addEventListener("pointerup", onPointerUp); - - const cursorMap: Record = { - left: "col-resize", - top: "row-resize", - corner: "nw-resize", - }; - document.body.style.cursor = cursorMap[dir]; - document.body.style.userSelect = "none"; + document.addEventListener("pointercancel", onPointerCancel); + captureEl.addEventListener("lostpointercapture", onLostPointerCapture); + window.addEventListener("blur", finishDrag); }, [renderWidth, renderHeight, setChatSize], );