diff --git a/packages/ui/components/ui/resizable.tsx b/packages/ui/components/ui/resizable.tsx index 305e07e6bee..30385703c46 100644 --- a/packages/ui/components/ui/resizable.tsx +++ b/packages/ui/components/ui/resizable.tsx @@ -3,6 +3,7 @@ import * as ResizablePrimitive from "react-resizable-panels" import { cn } from "@multica/ui/lib/utils" +import { resizeHandleVariants } from "@multica/ui/components/ui/resize-handle" function ResizablePanelGroup({ className, @@ -24,18 +25,45 @@ function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) { return } +// The separator owns the divider line between two panels: it renders the +// resting rule itself rather than sitting on top of a border the panels draw. +// Two elements painting the same 1px meant the hover/active states were +// invisible — they were tinting a line that was already there. Panels either +// side must not add their own edge border. +// +// Cursor and the drag-time cursor lock stay with the library, which injects +// `*, *:hover { cursor: … !important }` document-wide — hence `cursor: "none"`. +// Everything visual comes from the shared variants, so a panel divider and the +// sidebar rail cannot drift apart. function ResizableHandle({ withHandle, + rule = true, className, ...props }: ResizablePrimitive.SeparatorProps & { withHandle?: boolean + // Set false when there is no panel on the far side to divide from — a + // collapsed sidebar, say. The grab hint still appears on hover. Callers get + // this instead of a class override so that which pseudo-element draws the + // rule stays an implementation detail. + rule?: boolean }) { return ( , "onPointerDown">, + VariantProps { + axis: ResizeAxis + onResize: (delta: ResizeDelta, event: PointerEvent) => void + onResizeStart?: (event: React.PointerEvent) => boolean | void + onResizeEnd?: (mode: ResizeEndMode) => void + threshold?: number + disabled?: boolean +} + +// Default shell for a resize handle. Deliberately unopinionated about +// semantics: callers supply their own role/tabIndex/aria, because a table +// column separator wants a focusable `role="separator"` while the chat +// window's edges are `aria-hidden` decorations behind a keyboard-reachable +// expand button. +function ResizeHandle({ + axis, + indicator, + hitArea = "self", + onResize, + onResizeStart, + onResizeEnd, + threshold, + disabled, + className, + ...props +}: ResizeHandleProps) { + const { onPointerDown } = useResizeGesture({ + axis, + threshold, + disabled, + onStart: onResizeStart, + onMove: onResize, + onEnd: onResizeEnd, + }) + + return ( +
+ ) +} + +export { ResizeHandle, resizeHandleVariants } diff --git a/packages/ui/components/ui/sidebar.tsx b/packages/ui/components/ui/sidebar.tsx index 3bdd0be3b42..ce5dbb7c0e2 100644 --- a/packages/ui/components/ui/sidebar.tsx +++ b/packages/ui/components/ui/sidebar.tsx @@ -7,8 +7,10 @@ import { cva, type VariantProps } from "class-variance-authority" import { useTranslation } from "react-i18next" import { useIsMobile } from "@multica/ui/hooks/use-mobile" +import { useResizeGesture, type ResizeDelta } from "@multica/ui/hooks/use-resize-gesture" import { cn } from "@multica/ui/lib/utils" import { Button } from "@multica/ui/components/ui/button" +import { resizeHandleVariants } from "@multica/ui/components/ui/resize-handle" import { Input } from "@multica/ui/components/ui/input" import { Separator } from "@multica/ui/components/ui/separator" import { @@ -322,8 +324,6 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { const toggleLabel = t(($) => $.toggle_sidebar) const didDragRef = React.useRef(false) const dragRef = React.useRef<{ - pointerId: number - startX: number startWidth: number latestWidth: number direction: 1 | -1 @@ -331,27 +331,21 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { gapEl: HTMLElement containerEl: HTMLElement } | null>(null) - const cancelActiveDragRef = React.useRef<(() => void) | null>(null) - React.useEffect(() => () => cancelActiveDragRef.current?.(), []) - - const onPointerDown = React.useCallback( - (e: React.PointerEvent) => { - if (e.button !== 0 || e.isPrimary === false) return - e.preventDefault() - cancelActiveDragRef.current?.() + const handleResizeStart = React.useCallback( + (e: React.PointerEvent) => { didDragRef.current = false const railEl = e.currentTarget const sidebarEl = railEl.closest("[data-slot='sidebar']") const wrapperEl = railEl.closest("[data-slot='sidebar-wrapper']") const gapEl = sidebarEl?.querySelector("[data-slot='sidebar-gap']") const containerEl = sidebarEl?.querySelector("[data-slot='sidebar-container']") - if (!sidebarEl || !wrapperEl || !gapEl || !containerEl) return + if (!sidebarEl || !wrapperEl || !gapEl || !containerEl) return false - const startWidth = clampSidebarWidth(containerEl.getBoundingClientRect().width) + const startWidth = clampSidebarWidth( + containerEl.getBoundingClientRect().width + ) dragRef.current = { - pointerId: e.pointerId, - startX: e.clientX, startWidth, latestWidth: startWidth, direction: sidebarEl.dataset.side === "right" ? -1 : 1, @@ -359,86 +353,63 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { gapEl, containerEl, } - wrapperEl.setAttribute("data-sidebar-resizing", "true") - document.documentElement.setAttribute("data-sidebar-resizing", "true") - - let finished = false - const finishDrag = (mode: "commit" | "cancel") => { - if (finished) return - finished = true - - document.removeEventListener("pointermove", onPointerMove) - document.removeEventListener("pointerup", onPointerUp) - document.removeEventListener("pointercancel", onPointerCancel) - window.removeEventListener("blur", onWindowBlur) - railEl.removeEventListener("lostpointercapture", onLostPointerCapture) - - const drag = dragRef.current - if (drag) { - if (mode === "commit" && didDragRef.current) { - drag.wrapperEl.style.setProperty( - "--sidebar-width", - `${drag.latestWidth}px` - ) - commitWidth(drag.latestWidth) - } - drag.gapEl.style.removeProperty("width") - drag.containerEl.style.removeProperty("width") - drag.wrapperEl.removeAttribute("data-sidebar-resizing") - } - - dragRef.current = null - cancelActiveDragRef.current = null - document.documentElement.removeAttribute("data-sidebar-resizing") - - if (railEl.hasPointerCapture?.(e.pointerId)) { - railEl.releasePointerCapture?.(e.pointerId) - } - } + return true + }, + [] + ) - const onPointerMove = (event: PointerEvent) => { - const drag = dragRef.current - if (!drag || event.pointerId !== drag.pointerId) return + const handleResize = React.useCallback(({ dx }: ResizeDelta) => { + const drag = dragRef.current + if (!drag) return + + // The gesture only reports past its threshold, so reaching here is what + // makes this a drag rather than a click-to-toggle. + didDragRef.current = true + const nextWidth = clampSidebarWidth(drag.startWidth + dx * drag.direction) + if (nextWidth === drag.latestWidth) return + + drag.latestWidth = nextWidth + // Only the two layout shells depend on the live width. Direct writes + // let the browser coalesce layout at paint time without a React commit + // or an inherited custom-property invalidation on the whole app tree. + drag.gapEl.style.width = `${nextWidth}px` + drag.containerEl.style.width = `${nextWidth}px` + }, []) - const delta = (event.clientX - drag.startX) * drag.direction - if (!didDragRef.current && Math.abs(delta) < SIDEBAR_DRAG_THRESHOLD) { - return + const handleResizeEnd = React.useCallback( + (mode: "commit" | "cancel") => { + const drag = dragRef.current + if (!drag) return + dragRef.current = null + + try { + if (mode === "commit" && didDragRef.current) { + drag.wrapperEl.style.setProperty( + "--sidebar-width", + `${drag.latestWidth}px` + ) + commitWidth(drag.latestWidth) } - - didDragRef.current = true - const nextWidth = clampSidebarWidth(drag.startWidth + delta) - if (nextWidth === drag.latestWidth) return - - drag.latestWidth = nextWidth - // Only the two layout shells depend on the live width. Direct writes - // let the browser coalesce layout at paint time without a React commit - // or an inherited custom-property invalidation on the whole app tree. - drag.gapEl.style.width = `${nextWidth}px` - drag.containerEl.style.width = `${nextWidth}px` - } - const onPointerUp = (event: PointerEvent) => { - if (event.pointerId === e.pointerId) finishDrag("commit") + } finally { + // Persisting the width can throw (storage quota); the preview must be + // torn down regardless or the sidebar keeps its inline drag width. + drag.gapEl.style.removeProperty("width") + drag.containerEl.style.removeProperty("width") + drag.wrapperEl.removeAttribute("data-sidebar-resizing") } - const onPointerCancel = (event: PointerEvent) => { - if (event.pointerId === e.pointerId) finishDrag("cancel") - } - const onLostPointerCapture = (event: PointerEvent) => { - if (event.pointerId === e.pointerId) finishDrag("cancel") - } - const onWindowBlur = () => finishDrag("cancel") - - document.addEventListener("pointermove", onPointerMove) - document.addEventListener("pointerup", onPointerUp) - document.addEventListener("pointercancel", onPointerCancel) - window.addEventListener("blur", onWindowBlur) - railEl.addEventListener("lostpointercapture", onLostPointerCapture) - cancelActiveDragRef.current = () => finishDrag("cancel") - railEl.setPointerCapture?.(e.pointerId) }, [commitWidth] ) + const { onPointerDown } = useResizeGesture({ + axis: "x", + threshold: SIDEBAR_DRAG_THRESHOLD, + onStart: handleResizeStart, + onMove: handleResize, + onEnd: handleResizeEnd, + }) + const handleClick = React.useCallback(() => { if (!didDragRef.current) toggleSidebar() }, [toggleSidebar]) @@ -454,10 +425,15 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { onPointerDown={onPointerDown} title={toggleLabel} className={cn( - "absolute inset-y-0 z-20 hidden w-4 touch-none cursor-ew-resize transition-[transform,background-color] ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2", + // `hitArea: "self"` sizes the rail to the shared 8px grab zone. The + // offsets below are half that width, so the rail straddles the sidebar + // edge; they have to move with it or the rail lands beside the edge + // instead of on it. + resizeHandleVariants({ axis: "x", indicator: "line", hitArea: "self" }), + "absolute inset-y-0 z-20 hidden transition-[transform,background-color] ease-linear group-data-[side=left]:-right-2 group-data-[side=right]:left-0 sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2", "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar", - "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", - "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", + "[[data-side=left][data-collapsible=offcanvas]_&]:-right-1", + "[[data-side=right][data-collapsible=offcanvas]_&]:-left-1", className )} {...props} diff --git a/packages/ui/hooks/use-resize-gesture.ts b/packages/ui/hooks/use-resize-gesture.ts new file mode 100644 index 00000000000..fa54364557c --- /dev/null +++ b/packages/ui/hooks/use-resize-gesture.ts @@ -0,0 +1,175 @@ +"use client" + +import * as React from "react" + +export type ResizeAxis = "x" | "y" | "xy" + +export type ResizeDelta = { + // Pointer displacement from where the gesture started, in CSS pixels. + // Sign, clamping and committing are the caller's job: every call site + // maps these to a different target (sidebar width, chat width/height) + // with a different sign convention. + dx: number + dy: number +} + +export type ResizeEndMode = "commit" | "cancel" + +const RESIZE_AXIS_ATTRIBUTE = "data-resize-axis" +const RESIZING_ATTRIBUTE = "data-resizing" + +export interface UseResizeGestureOptions { + axis: ResizeAxis + // Return false to abort the gesture before it starts (for example when the + // layout elements the caller needs are not mounted). + onStart?: (event: React.PointerEvent) => boolean | void + // Called only after `threshold` has been exceeded, so the first call always + // means "the user is really dragging", not "the user clicked". + onMove: (delta: ResizeDelta, event: PointerEvent) => void + onEnd?: (mode: ResizeEndMode) => void + // Pointer travel required before the gesture counts as a drag. + threshold?: number + disabled?: boolean +} + +function exceedsThreshold(axis: ResizeAxis, delta: ResizeDelta, threshold: number) { + if (threshold <= 0) return true + switch (axis) { + case "x": + return Math.abs(delta.dx) >= threshold + case "y": + return Math.abs(delta.dy) >= threshold + case "xy": + return Math.max(Math.abs(delta.dx), Math.abs(delta.dy)) >= threshold + } +} + +// Shared pointer-drag gesture for every hand-written resize handle. +// +// It owns exactly three things, which are the three contracts that used to be +// re-implemented (and got out of sync) at each call site: +// +// 1. The global cursor lock. `data-resize-axis` on drives the +// `!important` rules in base.css, so the resize cursor survives leaving +// the handle and beats descendants that declare their own cursor. +// 2. `data-resizing` on the handle, for the active indicator. Set +// imperatively so a live drag never goes through React state. +// 3. Teardown. A single `finishDrag` covers pointerup, pointercancel, +// lostpointercapture, window blur, `disabled` flipping mid-drag, unmount +// and a throwing callback. Leaking `data-resize-axis` would lock the +// cursor for the whole document until reload, so every path runs it. +// +// State stays with the caller: this hook holds no React state and never +// re-renders on pointer move. +export function useResizeGesture({ + axis, + onStart, + onMove, + onEnd, + threshold = 0, + disabled = false, +}: UseResizeGestureOptions) { + const cancelActiveDragRef = React.useRef<(() => void) | null>(null) + + // Keep callbacks in refs so an in-flight drag always calls the latest + // version without re-subscribing listeners mid-gesture. + const onStartRef = React.useRef(onStart) + const onMoveRef = React.useRef(onMove) + const onEndRef = React.useRef(onEnd) + onStartRef.current = onStart + onMoveRef.current = onMove + onEndRef.current = onEnd + + React.useEffect(() => () => cancelActiveDragRef.current?.(), []) + + React.useEffect(() => { + if (disabled) cancelActiveDragRef.current?.() + }, [disabled]) + + const onPointerDown = React.useCallback( + (event: React.PointerEvent) => { + if (disabled) return + if (event.button !== 0 || event.isPrimary === false) return + + const handleEl = event.currentTarget + const { pointerId } = event + + event.preventDefault() + cancelActiveDragRef.current?.() + + if (onStartRef.current?.(event) === false) return + + const startX = event.clientX + const startY = event.clientY + let moved = false + let finished = false + + const finishDrag = (mode: ResizeEndMode) => { + if (finished) return + finished = true + + document.removeEventListener("pointermove", handlePointerMove) + document.removeEventListener("pointerup", handlePointerUp) + document.removeEventListener("pointercancel", handlePointerCancel) + window.removeEventListener("blur", handleWindowBlur) + handleEl.removeEventListener("lostpointercapture", handleLostPointerCapture) + + try { + onEndRef.current?.(mode) + } finally { + document.documentElement.removeAttribute(RESIZE_AXIS_ATTRIBUTE) + handleEl.removeAttribute(RESIZING_ATTRIBUTE) + cancelActiveDragRef.current = null + if (handleEl.hasPointerCapture?.(pointerId)) { + handleEl.releasePointerCapture?.(pointerId) + } + } + } + + const handlePointerMove = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) return + + const delta: ResizeDelta = { + dx: moveEvent.clientX - startX, + dy: moveEvent.clientY - startY, + } + if (!moved) { + if (!exceedsThreshold(axis, delta, threshold)) return + moved = true + } + + try { + onMoveRef.current(delta, moveEvent) + } catch (error) { + // Never leave the document cursor locked because a caller threw. + finishDrag("cancel") + throw error + } + } + const handlePointerUp = (upEvent: PointerEvent) => { + if (upEvent.pointerId === pointerId) finishDrag("commit") + } + const handlePointerCancel = (cancelEvent: PointerEvent) => { + if (cancelEvent.pointerId === pointerId) finishDrag("cancel") + } + const handleLostPointerCapture = (lostEvent: Event) => { + if ((lostEvent as PointerEvent).pointerId === pointerId) finishDrag("cancel") + } + const handleWindowBlur = () => finishDrag("cancel") + + document.documentElement.setAttribute(RESIZE_AXIS_ATTRIBUTE, axis) + handleEl.setAttribute(RESIZING_ATTRIBUTE, "true") + + document.addEventListener("pointermove", handlePointerMove) + document.addEventListener("pointerup", handlePointerUp) + document.addEventListener("pointercancel", handlePointerCancel) + window.addEventListener("blur", handleWindowBlur) + handleEl.addEventListener("lostpointercapture", handleLostPointerCapture) + cancelActiveDragRef.current = () => finishDrag("cancel") + handleEl.setPointerCapture?.(pointerId) + }, + [axis, disabled, threshold] + ) + + return { onPointerDown } +} diff --git a/packages/ui/styles/base.css b/packages/ui/styles/base.css index 270a38a9101..4e1c9be46b9 100644 --- a/packages/ui/styles/base.css +++ b/packages/ui/styles/base.css @@ -218,26 +218,40 @@ color: var(--sidebar-accent-foreground); } -/* Sidebar resizing follows the same cursor contract as - * react-resizable-panels: the resize cursor survives leaving the narrow rail - * and wins over descendants with their own cursor declarations. Live width is - * written directly to the two layout shells, so their toggle transitions must - * stay disabled until the gesture finishes. */ -html[data-sidebar-resizing="true"], -html[data-sidebar-resizing="true"] * { - cursor: ew-resize !important; +/* Resize handles (see useResizeGesture) follow the same cursor contract as + * react-resizable-panels: for the whole gesture the resize cursor survives + * leaving the narrow handle and wins over descendants with their own cursor + * declarations. react-resizable-panels states the same rule through an adopted + * stylesheet (`*, *:hover { cursor: ... !important }`); these selectors are + * more specific, so an in-flight drag of ours still wins while the pointer + * passes over one of its separators. */ +html[data-resize-axis], +html[data-resize-axis] * { user-select: none !important; } +html[data-resize-axis="x"], +html[data-resize-axis="x"] * { + cursor: ew-resize !important; +} + +html[data-resize-axis="y"], +html[data-resize-axis="y"] * { + cursor: ns-resize !important; +} + +html[data-resize-axis="xy"], +html[data-resize-axis="xy"] * { + cursor: nwse-resize !important; +} + +/* Live sidebar width is written directly to the two layout shells, so their + * toggle transitions must stay disabled until the gesture finishes. */ [data-sidebar-resizing="true"] [data-slot="sidebar-gap"], [data-sidebar-resizing="true"] [data-slot="sidebar-container"] { transition: none !important; } -[data-sidebar-resizing="true"] [data-sidebar="rail"]::after { - background-color: var(--sidebar-border); -} - /* Right detail sidebars use react-resizable-panels, which sizes panels by * updating the outer panel's flex-grow. Animate that property for button * toggles only; mount-time layout restoration and resize sync should snap diff --git a/packages/views/chat/chat-page.tsx b/packages/views/chat/chat-page.tsx index b0b17e84327..2c1ba072c9e 100644 --- a/packages/views/chat/chat-page.tsx +++ b/packages/views/chat/chat-page.tsx @@ -314,7 +314,7 @@ export function ChatPage() { maxSize={480} groupResizeBehavior="preserve-pixel-size" > -
+
{listHeader}
{listBody}
diff --git a/packages/views/chat/components/chat-resize-handles.tsx b/packages/views/chat/components/chat-resize-handles.tsx index df67b60c9f6..a2d8b2472f6 100644 --- a/packages/views/chat/components/chat-resize-handles.tsx +++ b/packages/views/chat/components/chat-resize-handles.tsx @@ -1,34 +1,62 @@ "use client"; -import React from "react"; +import { ResizeHandle } from "@multica/ui/components/ui/resize-handle"; +import type { + ResizeAxis, + ResizeDelta, +} from "@multica/ui/hooks/use-resize-gesture"; +import { cn } from "@multica/ui/lib/utils"; -type DragDir = "left" | "top" | "corner"; +import type { DragDir } from "./use-chat-resize"; interface ChatResizeHandlesProps { - onDragStart: (e: React.PointerEvent, dir: DragDir) => void; + onResizeStart: () => void; + onResize: (dir: DragDir, delta: ResizeDelta) => void; + onResizeEnd: () => void; } -export function ChatResizeHandles({ onDragStart }: ChatResizeHandlesProps) { +// The window is anchored bottom-right, so only the left edge, top edge and +// top-left corner are draggable. Edge handles start past the corner's 16px box +// so the three hit areas never overlap; the indicator sits on the window edge +// itself rather than centred in the hit area. +const HANDLES: { + dir: DragDir; + axis: ResizeAxis; + className: string; +}[] = [ + // Width/height come from the shared 8px grab zone; only the placement is + // ours. The corner is the one hit area that is deliberately square. + { + dir: "left", + axis: "x", + className: "left-0 top-4 bottom-0 z-10 after:start-0", + }, + { + dir: "top", + axis: "y", + className: "top-0 left-4 right-0 z-10 after:top-0", + }, + { dir: "corner", axis: "xy", className: "top-0 left-0 size-4 z-20" }, +]; + +export function ChatResizeHandles({ + onResizeStart, + onResize, + onResizeEnd, +}: ChatResizeHandlesProps) { return ( <> - {/* Left edge — expands width when dragged left */} -
onDragStart(e, "left")} - className="absolute left-0 top-4 bottom-0 w-1 z-10 cursor-col-resize" - /> - {/* Top edge — expands height when dragged up */} -
onDragStart(e, "top")} - className="absolute top-0 left-4 right-0 h-1 z-10 cursor-row-resize" - /> - {/* Top-left corner — expands both width and height */} -
onDragStart(e, "corner")} - className="absolute top-0 left-0 size-4 z-20 cursor-nw-resize" - /> + {HANDLES.map(({ dir, axis, className }) => ( + onResize(dir, delta)} + onResizeEnd={onResizeEnd} + /> + ))} ); } diff --git a/packages/views/chat/components/chat-resize.test.tsx b/packages/views/chat/components/chat-resize.test.tsx new file mode 100644 index 00000000000..6fed92765a3 --- /dev/null +++ b/packages/views/chat/components/chat-resize.test.tsx @@ -0,0 +1,172 @@ +import { fireEvent, render, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => { + const store = { + chatWidth: 400, + chatHeight: 500, + isExpanded: false, + setChatSize: vi.fn(), + setExpanded: vi.fn(), + }; + return { store }; +}); + +vi.mock("@multica/core/chat", () => { + const useChatStore = Object.assign( + (selector: (s: typeof h.store) => unknown) => selector(h.store), + { getState: () => h.store }, + ); + return { useChatStore, CHAT_MIN_W: 360, CHAT_MIN_H: 480 }; +}); + +import { ChatResizeHandles } from "./chat-resize-handles"; +import { useChatResize } from "./use-chat-resize"; + +function renderHandles() { + const onResizeStart = vi.fn(); + const onResize = vi.fn(); + const onResizeEnd = vi.fn(); + const { container } = render( + , + ); + const handles = Array.from( + container.querySelectorAll("[data-slot='resize-handle']"), + ); + for (const handle of handles) { + handle.setPointerCapture = vi.fn(); + handle.hasPointerCapture = vi.fn(() => true); + handle.releasePointerCapture = vi.fn(); + } + return { handles, onResizeStart, onResize, onResizeEnd }; +} + +describe("chat resize handles", () => { + beforeEach(() => { + document.documentElement.removeAttribute("data-resize-axis"); + h.store.chatWidth = 400; + h.store.chatHeight = 500; + h.store.isExpanded = false; + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // Before the shared primitive these three used col-resize/row-resize/nw-resize, + // none of which match what the rest of the product renders. + it("uses the shared resize cursors for the left edge, top edge and corner", () => { + const { handles } = renderHandles(); + + expect(handles).toHaveLength(3); + expect(handles[0]).toHaveClass("cursor-ew-resize"); + expect(handles[1]).toHaveClass("cursor-ns-resize"); + expect(handles[2]).toHaveClass("cursor-nwse-resize"); + + for (const handle of handles) { + expect(handle.className).not.toMatch(/cursor-(col|row|nw)-resize/); + expect(handle).toHaveAttribute("aria-hidden"); + } + }); + + it("gives the two edges a hover indicator", () => { + const { handles } = renderHandles(); + + expect(handles[0]).toHaveClass("hover:after:bg-foreground/15"); + expect(handles[1]).toHaveClass("hover:after:bg-foreground/15"); + // A corner has no single edge to draw along. + expect(handles[2]).toHaveClass("after:hidden"); + }); + + it("locks the document cursor per axis while dragging and clears it after", () => { + const { handles } = renderHandles(); + + fireEvent.pointerDown(handles[1]!, { button: 0, isPrimary: true, pointerId: 3 }); + expect(document.documentElement).toHaveAttribute("data-resize-axis", "y"); + + fireEvent.pointerUp(document, { pointerId: 3 }); + expect(document.documentElement).not.toHaveAttribute("data-resize-axis"); + }); + + it("reports each handle's direction to the caller", () => { + const { handles, onResize, onResizeStart, onResizeEnd } = renderHandles(); + + fireEvent.pointerDown(handles[2]!, { button: 0, clientX: 0, clientY: 0, isPrimary: true, pointerId: 4 }); + expect(onResizeStart).toHaveBeenCalledTimes(1); + + fireEvent.pointerMove(document, { clientX: -30, clientY: -20, pointerId: 4 }); + expect(onResize).toHaveBeenCalledWith("corner", { dx: -30, dy: -20 }); + + fireEvent.pointerUp(document, { pointerId: 4 }); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + }); +}); + +describe("useChatResize", () => { + beforeEach(() => { + h.store.chatWidth = 400; + h.store.chatHeight = 500; + h.store.isExpanded = false; + vi.clearAllMocks(); + }); + + function setup() { + const ref = { current: null } as React.RefObject; + return renderHook(() => useChatResize(ref)); + } + + // The window is anchored bottom-right: dragging the left edge left (negative + // dx) has to make it wider, not narrower. + it("grows the window when the left edge is dragged away from the anchor", () => { + const { result } = setup(); + + result.current.handleResizeStart(); + result.current.handleResize("left", { dx: -50, dy: 0 }); + + expect(h.store.setChatSize).toHaveBeenCalledWith(450, 500); + }); + + it("grows the window when the top edge is dragged up", () => { + const { result } = setup(); + + result.current.handleResizeStart(); + result.current.handleResize("top", { dx: 0, dy: -40 }); + + expect(h.store.setChatSize).toHaveBeenCalledWith(400, 540); + }); + + it("moves both axes from the corner", () => { + const { result } = setup(); + + result.current.handleResizeStart(); + result.current.handleResize("corner", { dx: -25, dy: -35 }); + + expect(h.store.setChatSize).toHaveBeenCalledWith(425, 535); + }); + + it("clamps to the minimum size", () => { + const { result } = setup(); + + result.current.handleResizeStart(); + result.current.handleResize("corner", { dx: 999, dy: 999 }); + + expect(h.store.setChatSize).toHaveBeenCalledWith(360, 480); + }); + + it("ignores movement that arrives without a live gesture", () => { + const { result } = setup(); + + result.current.handleResize("left", { dx: -50, dy: 0 }); + expect(h.store.setChatSize).not.toHaveBeenCalled(); + + result.current.handleResizeStart(); + result.current.handleResizeEnd(); + result.current.handleResize("left", { dx: -50, dy: 0 }); + expect(h.store.setChatSize).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/views/chat/components/chat-window.tsx b/packages/views/chat/components/chat-window.tsx index 133fa2ddefc..e7d547330c7 100644 --- a/packages/views/chat/components/chat-window.tsx +++ b/packages/views/chat/components/chat-window.tsx @@ -687,7 +687,17 @@ export function ChatWindow() { const isExpanded = useChatStore((s) => s.isExpanded); const windowRef = useRef(null); - const { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag } = useChatResize(windowRef); + const { + renderWidth, + renderHeight, + isAtMax, + boundsReady, + isDragging, + toggleExpand, + handleResizeStart, + handleResize, + handleResizeEnd, + } = useChatResize(windowRef); // Show the list (vs empty state) as soon as there's anything to display — // a real message, or a pending task whose timeline will stream in. @@ -722,7 +732,11 @@ export function ChatWindow() { scale: { type: "spring", duration: 0.2, bounce: 0 }, }} > - + {/* Header — ⊕ new + session dropdown | window tools */}
diff --git a/packages/views/chat/components/use-chat-resize.ts b/packages/views/chat/components/use-chat-resize.ts index 01a53ddeb73..878d55dc180 100644 --- a/packages/views/chat/components/use-chat-resize.ts +++ b/packages/views/chat/components/use-chat-resize.ts @@ -2,8 +2,9 @@ import React, { useRef, useCallback, useState, useEffect } from "react"; import { CHAT_MIN_W, CHAT_MIN_H, useChatStore } from "@multica/core/chat"; +import type { ResizeDelta } from "@multica/ui/hooks/use-resize-gesture"; -type DragDir = "left" | "top" | "corner"; +export type DragDir = "left" | "top" | "corner"; const MAX_RATIO = 0.9; const FALLBACK_MAX_W = 800; @@ -73,68 +74,46 @@ export function useChatResize( }, [isExpanded, isAtMax, setChatSize, setExpanded]); // ── Drag ────────────────────────────────────────────────────────────── - const dragRef = useRef<{ - startX: number; - startY: number; - startW: number; - startH: number; - dir: DragDir; - } | null>(null); - - const startDrag = useCallback( - (e: React.PointerEvent, dir: DragDir) => { - e.preventDefault(); - (e.target as HTMLElement).setPointerCapture(e.pointerId); - - dragRef.current = { - startX: e.clientX, - startY: e.clientY, - startW: renderWidth, - startH: renderHeight, - dir, - }; - setIsDragging(true); - - const onPointerMove = (ev: PointerEvent) => { - const d = dragRef.current; - if (!d) return; - - const { maxW: mw, maxH: mh } = boundsRef.current; - - const rawW = - dir === "left" || dir === "corner" - ? d.startW - (ev.clientX - d.startX) - : d.startW; - const rawH = - dir === "top" || dir === "corner" - ? d.startH - (ev.clientY - d.startY) - : d.startH; - - 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 = ""; - }; - - 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"; + // The pointer gesture itself (capture, cursor lock, teardown) belongs to + // useResizeGesture in the handles; this hook only owns the size maths. + const dragRef = useRef<{ startW: number; startH: number } | null>(null); + + const handleResizeStart = useCallback(() => { + dragRef.current = { startW: renderWidth, startH: renderHeight }; + setIsDragging(true); + }, [renderWidth, renderHeight]); + + const handleResize = useCallback( + (dir: DragDir, { dx, dy }: ResizeDelta) => { + const d = dragRef.current; + if (!d) return; + + const { maxW: mw, maxH: mh } = boundsRef.current; + + // The window is anchored bottom-right, so dragging the left/top edges + // away from it (negative delta) grows the window. + const rawW = dir === "left" || dir === "corner" ? d.startW - dx : d.startW; + const rawH = dir === "top" || dir === "corner" ? d.startH - dy : d.startH; + + setChatSize(clamp(rawW, CHAT_MIN_W, mw), clamp(rawH, CHAT_MIN_H, mh)); }, - [renderWidth, renderHeight, setChatSize], + [setChatSize], ); - return { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag }; + const handleResizeEnd = useCallback(() => { + dragRef.current = null; + setIsDragging(false); + }, []); + + return { + renderWidth, + renderHeight, + isAtMax, + boundsReady, + isDragging, + toggleExpand, + handleResizeStart, + handleResize, + handleResizeEnd, + }; } diff --git a/packages/views/inbox/components/inbox-page.tsx b/packages/views/inbox/components/inbox-page.tsx index d39eba6a272..5ea7f6e24cd 100644 --- a/packages/views/inbox/components/inbox-page.tsx +++ b/packages/views/inbox/components/inbox-page.tsx @@ -578,7 +578,7 @@ export function InboxPage() { return ( -
+
@@ -609,7 +609,7 @@ export function InboxPage() { return ( -
+
{listPanel}
diff --git a/packages/views/issues/components/issue-detail.tsx b/packages/views/issues/components/issue-detail.tsx index 6e584e1e74b..8f0c353350d 100644 --- a/packages/views/issues/components/issue-detail.tsx +++ b/packages/views/issues/components/issue-detail.tsx @@ -2503,7 +2503,9 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr {detailContent} - + {/* No resting rule while the sidebar is collapsed — there is no panel + on the far side to divide from. Hover still reveals the grab hint. */} + ({ + Group: ({ children, ...props }: React.ComponentProps<"div">) => ( +
{children}
+ ), + Panel: ({ children, ...props }: React.ComponentProps<"div">) => ( +
{children}
+ ), + Separator: ({ children, ...props }: React.ComponentProps<"div">) => ( +
{children}
+ ), +})); + +import { ResizableHandle } from "@multica/ui/components/ui/resizable"; +import { resizeHandleVariants } from "@multica/ui/components/ui/resize-handle"; + +// The panel divider must be drawn by exactly one element. When a panel also +// painted its own border-r/border-l, the handle's hover and active states were +// tinting a line that was already there, so they read as no change at all. +describe("resizable divider ownership", () => { + function renderHandle( + props?: Partial>, + ) { + const { container } = render(); + return container.querySelector("[data-slot='resizable-handle']")!; + } + + it("renders the resting divider rule itself", () => { + expect(renderHandle()).toHaveClass("after:bg-border"); + }); + + it("darkens on hover and while dragging", () => { + const handle = renderHandle(); + expect(handle).toHaveClass("hover:after:bg-foreground/15"); + expect(handle).toHaveClass("data-[separator=active]:after:bg-foreground/25"); + }); + + // A collapsed sidebar has no panel on the far side, so the resting rule has + // to go while the grab hint stays. + // + // The assertion deliberately names no pseudo-element: the last regression + // was exactly that. The rule moved from ::before to ::after and the two + // callers kept overriding ::before via className, so they silently stopped + // hiding anything. `rule` is a prop now — which pseudo draws the line is the + // component's business, not the caller's. + it("drops the resting rule when there is nothing to divide", () => { + const handle = renderHandle({ rule: false }); + expect(handle.className).not.toMatch(/:bg-border/); + expect(handle).toHaveClass("hover:after:bg-foreground/15"); + expect(handle).toHaveClass("data-[separator=active]:after:bg-foreground/25"); + }); + + // The whole point of the sweep: the panel divider and the hand-written + // handles read their look from one definition. If someone re-hardcodes the + // tokens in resizable.tsx, these drift apart and this fails. + it("takes its look from the shared variants, not a local copy", () => { + const shared = resizeHandleVariants({ + axis: "x", + cursor: "none", + indicator: "rule", + hitArea: "overlay", + }); + const handle = renderHandle(); + for (const cls of shared.split(" ").filter(Boolean)) { + expect(handle).toHaveClass(cls); + } + }); + + it("leaves the cursor to the library", () => { + expect(renderHandle().className).not.toMatch(/(^|\s|:)cursor-/); + }); + + it("uses the shared 8px grab zone", () => { + expect(renderHandle()).toHaveClass("before:w-2"); + }); +}); diff --git a/packages/views/layout/resize-handle.test.tsx b/packages/views/layout/resize-handle.test.tsx new file mode 100644 index 00000000000..0c65226d241 --- /dev/null +++ b/packages/views/layout/resize-handle.test.tsx @@ -0,0 +1,241 @@ +import { fireEvent, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ResizeHandle } from "@multica/ui/components/ui/resize-handle"; +import type { ResizeAxis } from "@multica/ui/hooks/use-resize-gesture"; + +function renderHandle( + props: Partial> = {}, +) { + const onResize = vi.fn(); + const onResizeEnd = vi.fn(); + const utils = render( + , + ); + const handle = utils.getByTestId("handle"); + handle.setPointerCapture = vi.fn(); + handle.hasPointerCapture = vi.fn(() => true); + handle.releasePointerCapture = vi.fn(); + return { ...utils, handle, onResize, onResizeEnd }; +} + +function startDrag(handle: HTMLElement, pointerId = 1) { + fireEvent.pointerDown(handle, { button: 0, clientX: 0, clientY: 0, isPrimary: true, pointerId }); +} + +// jsdom turns an exception thrown inside an event listener into an uncaught +// error on window rather than propagating it to the dispatcher, so a throwing +// callback can only be observed this way. +function swallowUncaughtErrors() { + const seen: string[] = []; + const onError = (event: ErrorEvent) => { + seen.push(event.error?.message ?? event.message); + event.preventDefault(); + }; + window.addEventListener("error", onError); + afterEach(() => window.removeEventListener("error", onError)); + return { errors: () => seen.join("\n") }; +} + +describe("ResizeHandle", () => { + beforeEach(() => { + document.documentElement.removeAttribute("data-resize-axis"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // The whole point of the primitive: a call site cannot pick the wrong + // cursor token. col-resize/row-resize are the Safari-only branch of + // react-resizable-panels and must never appear on a Multica runtime. + it.each([ + ["x", "cursor-ew-resize"], + ["y", "cursor-ns-resize"], + ["xy", "cursor-nwse-resize"], + ] as [ResizeAxis, string][])("maps axis %s to %s", (axis, expected) => { + const { handle } = renderHandle({ axis }); + expect(handle).toHaveClass(expected); + expect(handle.className).not.toMatch(/cursor-(col|row)-resize/); + }); + + it.each([ + ["x", "x"], + ["y", "y"], + ["xy", "xy"], + ] as [ResizeAxis, string][])( + "locks the document cursor to axis %s for the duration of the drag", + (axis, expected) => { + const { handle } = renderHandle({ axis }); + + startDrag(handle); + expect(document.documentElement).toHaveAttribute("data-resize-axis", expected); + expect(handle).toHaveAttribute("data-resizing", "true"); + + fireEvent.pointerUp(document, { pointerId: 1 }); + expect(document.documentElement).not.toHaveAttribute("data-resize-axis"); + expect(handle).not.toHaveAttribute("data-resizing"); + }, + ); + + it("reports raw pointer deltas and commits on pointerup", () => { + const { handle, onResize, onResizeEnd } = renderHandle(); + + startDrag(handle); + fireEvent.pointerMove(document, { clientX: 40, clientY: 12, pointerId: 1 }); + + expect(onResize).toHaveBeenCalledTimes(1); + expect(onResize.mock.calls[0]![0]).toEqual({ dx: 40, dy: 12 }); + + fireEvent.pointerUp(document, { pointerId: 1 }); + expect(onResizeEnd).toHaveBeenCalledWith("commit"); + }); + + it("does not report movement below the threshold", () => { + const { handle, onResize } = renderHandle({ threshold: 10 }); + + startDrag(handle); + fireEvent.pointerMove(document, { clientX: 4, clientY: 0, pointerId: 1 }); + expect(onResize).not.toHaveBeenCalled(); + + fireEvent.pointerMove(document, { clientX: 12, clientY: 0, pointerId: 1 }); + expect(onResize).toHaveBeenCalledTimes(1); + }); + + it("aborts the gesture when onResizeStart returns false", () => { + const { handle, onResize } = renderHandle({ onResizeStart: () => false }); + + startDrag(handle); + expect(document.documentElement).not.toHaveAttribute("data-resize-axis"); + + fireEvent.pointerMove(document, { clientX: 40, pointerId: 1 }); + expect(onResize).not.toHaveBeenCalled(); + }); + + it("ignores non-primary and secondary buttons", () => { + const { handle } = renderHandle(); + + fireEvent.pointerDown(handle, { button: 2, isPrimary: true, pointerId: 1 }); + expect(document.documentElement).not.toHaveAttribute("data-resize-axis"); + + fireEvent.pointerDown(handle, { button: 0, isPrimary: false, pointerId: 1 }); + expect(document.documentElement).not.toHaveAttribute("data-resize-axis"); + }); + + // A leaked data-resize-axis locks the cursor for the entire document until + // reload, so every teardown path is covered explicitly. + describe("cursor lock teardown", () => { + it("releases on pointercancel", () => { + const { handle, onResizeEnd } = renderHandle(); + startDrag(handle); + fireEvent.pointerCancel(document, { pointerId: 1 }); + + expect(document.documentElement).not.toHaveAttribute("data-resize-axis"); + expect(onResizeEnd).toHaveBeenCalledWith("cancel"); + }); + + it("releases on lostpointercapture", () => { + const { handle, onResizeEnd } = renderHandle(); + startDrag(handle); + fireEvent(handle, new PointerEvent("lostpointercapture", { pointerId: 1 })); + + expect(document.documentElement).not.toHaveAttribute("data-resize-axis"); + expect(onResizeEnd).toHaveBeenCalledWith("cancel"); + }); + + it("releases on window blur", () => { + const { handle, onResizeEnd } = renderHandle(); + startDrag(handle); + fireEvent.blur(window); + + expect(document.documentElement).not.toHaveAttribute("data-resize-axis"); + expect(onResizeEnd).toHaveBeenCalledWith("cancel"); + }); + + it("releases on unmount mid-drag", () => { + const { handle, unmount, onResizeEnd } = renderHandle(); + startDrag(handle); + unmount(); + + expect(document.documentElement).not.toHaveAttribute("data-resize-axis"); + expect(onResizeEnd).toHaveBeenCalledWith("cancel"); + }); + + it("releases when disabled flips mid-drag", () => { + const onResize = vi.fn(); + const onResizeEnd = vi.fn(); + const { rerender, getByTestId } = render( + , + ); + const handle = getByTestId("handle"); + handle.setPointerCapture = vi.fn(); + handle.hasPointerCapture = vi.fn(() => true); + handle.releasePointerCapture = vi.fn(); + + startDrag(handle); + expect(document.documentElement).toHaveAttribute("data-resize-axis", "x"); + + rerender( + , + ); + + expect(document.documentElement).not.toHaveAttribute("data-resize-axis"); + expect(onResizeEnd).toHaveBeenCalledWith("cancel"); + + fireEvent.pointerMove(document, { clientX: 40, pointerId: 1 }); + expect(onResize).not.toHaveBeenCalled(); + }); + + // The error still propagates (a throwing caller is a real bug and should + // reach error reporting); what must not happen is the document staying + // cursor-locked because of it. + it("releases when the caller's onResize throws", () => { + const { errors } = swallowUncaughtErrors(); + const onResize = vi.fn(() => { + throw new Error("boom"); + }); + const { handle } = renderHandle({ onResize }); + + startDrag(handle); + fireEvent.pointerMove(document, { clientX: 40, pointerId: 1 }); + + expect(errors()).toContain("boom"); + expect(document.documentElement).not.toHaveAttribute("data-resize-axis"); + expect(handle).not.toHaveAttribute("data-resizing"); + }); + + it("releases when the caller's onResizeEnd throws", () => { + const { errors } = swallowUncaughtErrors(); + const onResizeEnd = vi.fn(() => { + throw new Error("commit failed"); + }); + const { handle } = renderHandle({ onResizeEnd }); + + startDrag(handle); + fireEvent.pointerUp(document, { pointerId: 1 }); + + expect(errors()).toContain("commit failed"); + expect(document.documentElement).not.toHaveAttribute("data-resize-axis"); + expect(handle).not.toHaveAttribute("data-resizing"); + }); + }); + + describe("indicator", () => { + it("shows a hover and active line by default", () => { + const { handle } = renderHandle(); + expect(handle).toHaveClass("hover:after:bg-foreground/15"); + expect(handle).toHaveClass("data-[resizing=true]:after:bg-foreground/25"); + }); + + it("can be turned off", () => { + const { handle } = renderHandle({ indicator: "none" }); + expect(handle.className).not.toMatch(/after:bg-foreground/); + }); + }); +}); diff --git a/packages/views/layout/sidebar-resize.test.tsx b/packages/views/layout/sidebar-resize.test.tsx index 06796b4f6a3..3c1c3c5b34d 100644 --- a/packages/views/layout/sidebar-resize.test.tsx +++ b/packages/views/layout/sidebar-resize.test.tsx @@ -12,7 +12,7 @@ import { renderWithI18n } from "../test/i18n"; describe("left sidebar resizing", () => { beforeEach(() => { localStorage.clear(); - document.documentElement.removeAttribute("data-sidebar-resizing"); + document.documentElement.removeAttribute("data-resize-axis"); }); afterEach(() => { @@ -72,7 +72,7 @@ describe("left sidebar resizing", () => { expect(setPointerCapture).toHaveBeenCalledWith(7); expect(rail).toHaveClass("cursor-ew-resize"); expect(wrapper).toHaveAttribute("data-sidebar-resizing", "true"); - expect(document.documentElement).toHaveAttribute("data-sidebar-resizing", "true"); + expect(document.documentElement).toHaveAttribute("data-resize-axis", "x"); fireEvent.pointerMove(document, { buttons: 1, clientX: 280, pointerId: 7 }); fireEvent.pointerMove(document, { buttons: 1, clientX: 300, pointerId: 7 }); @@ -92,7 +92,7 @@ describe("left sidebar resizing", () => { expect(setItem).toHaveBeenCalledWith("sidebar_width", "300"); expect(releasePointerCapture).toHaveBeenCalledWith(7); expect(wrapper).not.toHaveAttribute("data-sidebar-resizing"); - expect(document.documentElement).not.toHaveAttribute("data-sidebar-resizing"); + expect(document.documentElement).not.toHaveAttribute("data-resize-axis"); expect(stableConsumerRender).toHaveBeenCalledTimes(1); fireEvent.click(rail); @@ -143,6 +143,6 @@ describe("left sidebar resizing", () => { expect(wrapper.style.getPropertyValue("--sidebar-width")).toBe("256px"); expect(setItem).not.toHaveBeenCalled(); expect(wrapper).not.toHaveAttribute("data-sidebar-resizing"); - expect(document.documentElement).not.toHaveAttribute("data-sidebar-resizing"); + expect(document.documentElement).not.toHaveAttribute("data-resize-axis"); }); }); diff --git a/packages/views/projects/components/project-detail.tsx b/packages/views/projects/components/project-detail.tsx index 2b19149332c..a5067243117 100644 --- a/packages/views/projects/components/project-detail.tsx +++ b/packages/views/projects/components/project-detail.tsx @@ -548,7 +548,9 @@ export function ProjectDetail({ projectId }: { projectId: string }) { />
- {!isMobile && } + {/* No resting rule while the sidebar is collapsed — there is no panel + on the far side to divide from. Hover still reveals the grab hint. */} + {!isMobile && } {!isMobile && (