fix(resize): keep drag cursor consistent for table & chat resize (MUL-5166) (#5824)

* 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 <html> 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 <github@multica.ai>

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* 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 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Naiyuan Qing
2026-07-23 16:20:24 +08:00
committed by GitHub
parent 40f9ecdd56
commit 975cd49c3f
4 changed files with 191 additions and 24 deletions

View File

@@ -126,27 +126,29 @@ export function DataTable<TData>({
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],
);

View File

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

View File

@@ -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<HTMLDivElement>(null);
const { startDrag } = useChatResize(ref);
return (
<div ref={ref} style={{ position: "relative" }}>
<ChatResizeHandles onDragStart={startDrag} />
</div>
);
}
const POINTER_ID = 11;
function startLeftDrag(container: HTMLElement) {
// The left edge handle is the one with the col-resize cursor.
const handle = container.querySelector<HTMLElement>(".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(<Harness />);
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(<Harness />);
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(<Harness />);
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(<Harness />);
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(<Harness />);
startLeftDrag(container);
fireEvent.blur(window);
expect(document.documentElement).not.toHaveAttribute("data-chat-resizing");
});
});

View File

@@ -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<DragDir, string> = {
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],
);