refactor(sidebar): update sidebar resizing logic to use pointer events; enhance drag cursor handling and improve sidebar width persistence

This commit is contained in:
Anish Sarkar 2026-05-04 01:47:17 +05:30
parent 331589275b
commit 147be71238
5 changed files with 208 additions and 166 deletions

View file

@ -10,18 +10,36 @@ export const SIDEBAR_MAX_WIDTH = 480;
interface UseSidebarResizeReturn { interface UseSidebarResizeReturn {
sidebarWidth: number; sidebarWidth: number;
handleMouseDown: (e: React.MouseEvent) => void; handlePointerDown: (e: React.PointerEvent<HTMLElement>) => void;
isDragging: boolean; isDragging: boolean;
} }
function setGlobalDragCursor(active: boolean) {
const html = document.documentElement;
const body = document.body;
if (active) {
html.style.cursor = "col-resize";
body.style.cursor = "col-resize";
html.style.userSelect = "none";
body.style.userSelect = "none";
} else {
html.style.cursor = "";
body.style.cursor = "";
html.style.userSelect = "";
body.style.userSelect = "";
}
}
export function useSidebarResize(defaultWidth = SIDEBAR_MIN_WIDTH): UseSidebarResizeReturn { export function useSidebarResize(defaultWidth = SIDEBAR_MIN_WIDTH): UseSidebarResizeReturn {
const [sidebarWidth, setSidebarWidth] = useState(defaultWidth); const [sidebarWidth, setSidebarWidth] = useState(defaultWidth);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const startXRef = useRef(0); const startXRef = useRef(0);
const startWidthRef = useRef(defaultWidth); const startWidthRef = useRef(defaultWidth);
const widthRef = useRef(defaultWidth);
const pointerIdRef = useRef<number | null>(null);
const captureTargetRef = useRef<HTMLElement | null>(null);
// Initialize from cookie on mount
useEffect(() => { useEffect(() => {
try { try {
const match = document.cookie.match(/(?:^|; )sidebar_width=([^;]+)/); const match = document.cookie.match(/(?:^|; )sidebar_width=([^;]+)/);
@ -29,14 +47,13 @@ export function useSidebarResize(defaultWidth = SIDEBAR_MIN_WIDTH): UseSidebarRe
const parsed = Number(match[1]); const parsed = Number(match[1]);
if (!Number.isNaN(parsed) && parsed >= SIDEBAR_MIN_WIDTH && parsed <= SIDEBAR_MAX_WIDTH) { if (!Number.isNaN(parsed) && parsed >= SIDEBAR_MIN_WIDTH && parsed <= SIDEBAR_MAX_WIDTH) {
setSidebarWidth(parsed); setSidebarWidth(parsed);
widthRef.current = parsed;
} }
} }
} catch { } catch {
// Ignore cookie read errors
} }
}, []); }, []);
// Persist width to cookie
const persistWidth = useCallback((width: number) => { const persistWidth = useCallback((width: number) => {
try { try {
// biome-ignore lint/suspicious/noDocumentCookie: SSR-readable preference, not security-sensitive // biome-ignore lint/suspicious/noDocumentCookie: SSR-readable preference, not security-sensitive
@ -46,57 +63,81 @@ export function useSidebarResize(defaultWidth = SIDEBAR_MIN_WIDTH): UseSidebarRe
} }
}, []); }, []);
const handleMouseDown = useCallback( const releaseCapture = useCallback(() => {
(e: React.MouseEvent) => { const target = captureTargetRef.current;
e.preventDefault(); const pointerId = pointerIdRef.current;
startXRef.current = e.clientX; if (target && pointerId !== null) {
startWidthRef.current = sidebarWidth; try {
setIsDragging(true); if (target.hasPointerCapture(pointerId)) {
target.releasePointerCapture(pointerId);
}
} catch {
}
}
captureTargetRef.current = null;
pointerIdRef.current = null;
}, []);
document.body.style.cursor = "col-resize"; const handlePointerDown = useCallback(
document.body.style.userSelect = "none"; (e: React.PointerEvent<HTMLElement>) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
e.preventDefault();
const target = e.currentTarget;
try {
target.setPointerCapture(e.pointerId);
} catch {
}
captureTargetRef.current = target;
pointerIdRef.current = e.pointerId;
startXRef.current = e.clientX;
startWidthRef.current = widthRef.current;
setIsDragging(true);
setGlobalDragCursor(true);
}, },
[sidebarWidth] []
); );
useEffect(() => { useEffect(() => {
if (!isDragging) return; if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => { const handlePointerMove = (e: PointerEvent) => {
if (pointerIdRef.current !== null && e.pointerId !== pointerIdRef.current) return;
const delta = e.clientX - startXRef.current; const delta = e.clientX - startXRef.current;
const newWidth = Math.min( const newWidth = Math.min(
SIDEBAR_MAX_WIDTH, SIDEBAR_MAX_WIDTH,
Math.max(SIDEBAR_MIN_WIDTH, startWidthRef.current + delta) Math.max(SIDEBAR_MIN_WIDTH, startWidthRef.current + delta)
); );
setSidebarWidth(newWidth); if (newWidth !== widthRef.current) {
widthRef.current = newWidth;
setSidebarWidth(newWidth);
}
}; };
const handleMouseUp = () => { const stop = (e: PointerEvent) => {
if (pointerIdRef.current !== null && e.pointerId !== pointerIdRef.current) return;
releaseCapture();
setIsDragging(false); setIsDragging(false);
document.body.style.cursor = ""; setGlobalDragCursor(false);
document.body.style.userSelect = ""; persistWidth(widthRef.current);
// Persist the final width
setSidebarWidth((currentWidth) => {
persistWidth(currentWidth);
return currentWidth;
});
}; };
document.addEventListener("mousemove", handleMouseMove); window.addEventListener("pointermove", handlePointerMove);
document.addEventListener("mouseup", handleMouseUp); window.addEventListener("pointerup", stop);
window.addEventListener("pointercancel", stop);
return () => { return () => {
document.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("mouseup", handleMouseUp); window.removeEventListener("pointerup", stop);
document.body.style.cursor = ""; window.removeEventListener("pointercancel", stop);
document.body.style.userSelect = ""; setGlobalDragCursor(false);
releaseCapture();
}; };
}, [isDragging, persistWidth]); }, [isDragging, persistWidth, releaseCapture]);
return { return {
sidebarWidth, sidebarWidth,
handleMouseDown, handlePointerDown,
isDragging, isDragging,
}; };
} }

View file

@ -11,7 +11,11 @@ import type { InboxItem } from "@/hooks/use-inbox";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SidebarProvider, useSidebarState } from "../../hooks"; import { SidebarProvider, useSidebarState } from "../../hooks";
import { useSidebarResize } from "../../hooks/useSidebarResize"; import {
SIDEBAR_MAX_WIDTH,
SIDEBAR_MIN_WIDTH,
useSidebarResize,
} from "../../hooks/useSidebarResize";
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types"; import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
import { Header } from "../header"; import { Header } from "../header";
import { IconRail } from "../icon-rail"; import { IconRail } from "../icon-rail";
@ -25,7 +29,6 @@ import {
MobileSidebarTrigger, MobileSidebarTrigger,
Sidebar, Sidebar,
} from "../sidebar"; } from "../sidebar";
import { SidebarCollapseButton } from "../sidebar/SidebarCollapseButton";
import { SidebarSlideOutPanel } from "../sidebar/SidebarSlideOutPanel"; import { SidebarSlideOutPanel } from "../sidebar/SidebarSlideOutPanel";
import { TabBar } from "../tabs/TabBar"; import { TabBar } from "../tabs/TabBar";
@ -123,13 +126,11 @@ function MainContentPanel({
isChatPage, isChatPage,
onTabSwitch, onTabSwitch,
onNewChat, onNewChat,
leftActions,
children, children,
}: { }: {
isChatPage: boolean; isChatPage: boolean;
onTabSwitch?: (tab: Tab) => void; onTabSwitch?: (tab: Tab) => void;
onNewChat?: () => void; onNewChat?: () => void;
leftActions?: React.ReactNode;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const activeTab = useAtomValue(activeTabAtom); const activeTab = useAtomValue(activeTabAtom);
@ -140,7 +141,6 @@ function MainContentPanel({
<TabBar <TabBar
onTabSwitch={onTabSwitch} onTabSwitch={onTabSwitch}
onNewChat={onNewChat} onNewChat={onNewChat}
leftActions={leftActions}
rightActions={<RightPanelExpandButton />} rightActions={<RightPanelExpandButton />}
className="min-w-0" className="min-w-0"
/> />
@ -214,7 +214,7 @@ export function LayoutShell({
const { isCollapsed, setIsCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed); const { isCollapsed, setIsCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed);
const { const {
sidebarWidth, sidebarWidth,
handleMouseDown: onResizeMouseDown, handlePointerDown: onResizePointerDown,
isDragging: isResizing, isDragging: isResizing,
} = useSidebarResize(); } = useSidebarResize();
@ -382,10 +382,7 @@ export function LayoutShell({
onSearchSpaceDelete={onSearchSpaceDelete} onSearchSpaceDelete={onSearchSpaceDelete}
onSearchSpaceSettings={onSearchSpaceSettings} onSearchSpaceSettings={onSearchSpaceSettings}
onAddSearchSpace={onAddSearchSpace} onAddSearchSpace={onAddSearchSpace}
isSingleRailMode={isCollapsed} isSingleRailMode={false}
onNewChat={onNewChat}
navItems={navItems}
onNavItemClick={onNavItemClick}
user={user} user={user}
onUserSettings={onUserSettings} onUserSettings={onUserSettings}
onAnnouncements={onAnnouncements} onAnnouncements={onAnnouncements}
@ -397,58 +394,54 @@ export function LayoutShell({
</div> </div>
{/* Sidebar + slide-out panels share one container; overflow visible so panels can overlay main content. Negative right margin closes the flex gap so the sidebar sits flush against the main panel, separated only by a border. */} {/* Sidebar + slide-out panels share one container; overflow visible so panels can overlay main content. Negative right margin closes the flex gap so the sidebar sits flush against the main panel, separated only by a border. */}
<div <div className="relative hidden md:flex shrink-0 z-20 -mr-2 border-r bg-panel">
className={cn( <Sidebar
"relative hidden md:flex shrink-0 z-20 -mr-2 border-r", searchSpace={searchSpace}
!isCollapsed && "bg-panel" isCollapsed={isCollapsed}
)} onToggleCollapse={toggleCollapsed}
> navItems={navItems}
{!isCollapsed && ( onNavItemClick={onNavItemClick}
<Sidebar chats={chats}
searchSpace={searchSpace} sharedChats={sharedChats}
isCollapsed={isCollapsed} activeChatId={activeChatId}
onToggleCollapse={toggleCollapsed} onNewChat={onNewChat}
navItems={navItems} onChatSelect={onChatSelect}
onNavItemClick={onNavItemClick} onChatRename={onChatRename}
chats={chats} onChatDelete={onChatDelete}
sharedChats={sharedChats} onChatArchive={onChatArchive}
activeChatId={activeChatId} onViewAllSharedChats={onViewAllSharedChats}
onNewChat={onNewChat} onViewAllPrivateChats={onViewAllPrivateChats}
onChatSelect={onChatSelect} isSharedChatsPanelOpen={activeSlideoutPanel === "shared"}
onChatRename={onChatRename} isPrivateChatsPanelOpen={activeSlideoutPanel === "private"}
onChatDelete={onChatDelete} user={user}
onChatArchive={onChatArchive} onSettings={onSettings}
onViewAllSharedChats={onViewAllSharedChats} onManageMembers={onManageMembers}
onViewAllPrivateChats={onViewAllPrivateChats} onUserSettings={onUserSettings}
isSharedChatsPanelOpen={activeSlideoutPanel === "shared"} onAnnouncements={onAnnouncements}
isPrivateChatsPanelOpen={activeSlideoutPanel === "private"} announcementUnreadCount={announcementUnreadCount}
user={user} onLogout={onLogout}
onSettings={onSettings} pageUsage={pageUsage}
onManageMembers={onManageMembers} theme={theme}
onUserSettings={onUserSettings} setTheme={setTheme}
onAnnouncements={onAnnouncements} renderUserProfile={false}
announcementUnreadCount={announcementUnreadCount} className="flex shrink-0"
onLogout={onLogout} isLoadingChats={isLoadingChats}
pageUsage={pageUsage} sidebarWidth={sidebarWidth}
theme={theme} isResizing={isResizing}
setTheme={setTheme} />
renderUserProfile={false}
className="flex shrink-0"
isLoadingChats={isLoadingChats}
sidebarWidth={sidebarWidth}
isResizing={isResizing}
/>
)}
{/* Drag hit-area straddling the right border wider for a forgiving grab,
z-50 + pointer-events-auto to beat any neighboring stacking context. */}
{!isCollapsed && ( {!isCollapsed && (
<button <hr
type="button" aria-orientation="vertical"
aria-label="Resize sidebar" aria-label="Resize sidebar"
onMouseDown={onResizeMouseDown} aria-valuemin={SIDEBAR_MIN_WIDTH}
aria-valuemax={SIDEBAR_MAX_WIDTH}
aria-valuenow={sidebarWidth}
tabIndex={0}
onPointerDown={onResizePointerDown}
style={{ touchAction: "none" }}
className={cn( className={cn(
"absolute top-0 right-0 h-full w-4 translate-x-1/2 cursor-col-resize z-50 pointer-events-auto select-none bg-transparent border-0 p-0 focus:outline-none", "absolute top-0 right-0 h-full w-4 translate-x-1/2 z-50 select-none cursor-col-resize",
"after:content-[''] after:absolute after:inset-y-0 after:left-1/2 after:w-px after:-translate-x-1/2 after:bg-transparent hover:after:bg-border/80 after:transition-colors", "after:content-[''] after:absolute after:inset-y-0 after:left-1/2 after:w-px after:-translate-x-1/2 after:bg-transparent hover:after:bg-border/80 after:transition-colors",
isResizing && "after:bg-border" isResizing && "after:bg-border"
)} )}
@ -518,11 +511,6 @@ export function LayoutShell({
isChatPage={isChatPage} isChatPage={isChatPage}
onTabSwitch={onTabSwitch} onTabSwitch={onTabSwitch}
onNewChat={onNewChat} onNewChat={onNewChat}
leftActions={
isCollapsed ? (
<SidebarCollapseButton isCollapsed={isCollapsed} onToggle={toggleCollapsed} />
) : undefined
}
> >
{children} {children}
</MainContentPanel> </MainContentPanel>

View file

@ -137,7 +137,7 @@ function CollapsedOverlay({ item }: { item: NavItem }) {
export function NavSection({ items, onItemClick, isCollapsed = false }: NavSectionProps) { export function NavSection({ items, onItemClick, isCollapsed = false }: NavSectionProps) {
return ( return (
<div className={cn("flex flex-col gap-0.5 py-2", isCollapsed && "items-center")}> <div className="flex flex-col gap-0.5 py-2">
{items.map((item) => { {items.map((item) => {
const { tooltip } = getStatusInfo(item.statusIndicator); const { tooltip } = getStatusInfo(item.statusIndicator);

View file

@ -113,45 +113,43 @@ export function Sidebar({
[navItems] [navItems]
); );
const collapsedWidth = 51;
return ( return (
<div <div
className={cn( className={cn(
"relative flex h-full flex-col bg-panel text-sidebar-foreground overflow-hidden select-none", "relative flex h-full flex-col bg-panel text-sidebar-foreground overflow-hidden select-none",
isCollapsed ? "w-[60px] transition-[width] duration-200" : "", !isResizing && "transition-[width] duration-200 ease-out",
!isCollapsed && !isResizing ? "transition-[width] duration-200" : "",
className className
)} )}
style={!isCollapsed ? { width: sidebarWidth } : undefined} style={{ width: isCollapsed ? collapsedWidth : sidebarWidth }}
> >
{/* Header - search space name or collapse button when collapsed */} <div className="flex h-12 shrink-0 items-center gap-0 px-1 border-b">
{isCollapsed ? ( <div
<div className="flex h-12 shrink-0 items-center justify-center border-b"> className={cn(
"min-w-0 overflow-hidden",
"transition-[max-width,opacity,margin-right] duration-200 ease-out",
isCollapsed ? "max-w-0 opacity-0 mr-0" : "max-w-[400px] flex-1 opacity-100"
)}
aria-hidden={isCollapsed}
>
<SidebarHeader
searchSpace={searchSpace}
isCollapsed={false}
onSettings={onSettings}
onManageMembers={onManageMembers}
/>
</div>
<div className={cn("shrink-0", isCollapsed && "mx-auto")}>
<SidebarCollapseButton <SidebarCollapseButton
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
onToggle={onToggleCollapse ?? (() => {})} onToggle={onToggleCollapse ?? (() => {})}
disableTooltip={disableTooltips} disableTooltip={disableTooltips}
/> />
</div> </div>
) : ( </div>
<div className="flex h-12 shrink-0 items-center gap-0 px-1 border-b">
<SidebarHeader
searchSpace={searchSpace}
isCollapsed={isCollapsed}
onSettings={onSettings}
onManageMembers={onManageMembers}
/>
<div className="shrink-0">
<SidebarCollapseButton
isCollapsed={isCollapsed}
onToggle={onToggleCollapse ?? (() => {})}
disableTooltip={disableTooltips}
/>
</div>
</div>
)}
{/* New chat button + Inbox */} <div className="flex flex-col gap-0.5 py-1.5">
<div className={cn("flex flex-col gap-0.5 py-1.5", isCollapsed && "items-center")}>
<SidebarButton <SidebarButton
icon={SquarePen} icon={SquarePen}
label={t("new_chat")} label={t("new_chat")}
@ -177,7 +175,7 @@ export function Sidebar({
{/* Chat sections - fills available space */} {/* Chat sections - fills available space */}
{isCollapsed ? ( {isCollapsed ? (
<div className="flex-1 w-[60px]" /> <div className="flex-1 w-full" />
) : ( ) : (
<div className="flex-1 flex flex-col gap-1 pt-2 w-full min-h-0 overflow-hidden"> <div className="flex-1 flex flex-col gap-1 pt-2 w-full min-h-0 overflow-hidden">
{/* Shared Chats Section - takes only space needed, max 50% */} {/* Shared Chats Section - takes only space needed, max 50% */}

View file

@ -27,15 +27,9 @@ interface SidebarButtonProps {
buttonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>; buttonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
} }
const expandedClassName = cn( const baseClassName = cn(
"flex items-center gap-2 rounded-md mx-2 px-2 py-1.5 text-sm transition-colors text-left", "group/sidebar-button relative flex h-9 items-center rounded-md mx-2 px-2 text-sm text-left",
"hover:bg-accent hover:text-accent-foreground", "transition-colors hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
);
const collapsedClassName = cn(
"relative flex h-10 w-10 items-center justify-center rounded-md transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
); );
@ -56,53 +50,67 @@ export function SidebarButton({
}: SidebarButtonProps) { }: SidebarButtonProps) {
const activeClassName = "bg-accent text-accent-foreground"; const activeClassName = "bg-accent text-accent-foreground";
if (isCollapsed) { const iconNode = isCollapsed
return ( ? (collapsedIconNode ?? <Icon className="h-3.5 w-3.5" />)
<Tooltip> : (expandedIconNode ?? <Icon className="h-3.5 w-3.5 shrink-0" />);
<TooltipTrigger asChild>
<button
type="button"
onClick={onClick}
className={cn(collapsedClassName, isActive && activeClassName, className)}
{...buttonProps}
>
{collapsedIconNode ?? <Icon className="h-3.5 w-3.5" />}
{collapsedOverlay}
<span className="sr-only">{label}</span>
</button>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
{tooltipContent ?? (
<>
{label}
{typeof badge === "string" && ` (${badge})`}
</>
)}
</TooltipContent>
</Tooltip>
);
}
const button = ( const button = (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className={cn(expandedClassName, isActive && activeClassName, className)} aria-label={isCollapsed ? label : undefined}
className={cn(baseClassName, isActive && activeClassName, className)}
{...buttonProps} {...buttonProps}
> >
{expandedIconNode ?? <Icon className="h-3.5 w-3.5 shrink-0" />} <span
<span className="flex-1 truncate">{label}</span> className={cn(
{trailingContent} "flex min-w-0 items-center translate-x-0.5 transition-transform duration-200 ease-out",
{badge && typeof badge !== "string" ? badge : null} isCollapsed ? "shrink-0" : "flex-1"
{badge && typeof badge === "string" ? ( )}
<span className="inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium"> >
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
{iconNode}
</span>
<span
className={cn(
"min-w-0 overflow-hidden whitespace-nowrap text-left",
"transition-[max-width,opacity,margin-left] duration-200 ease-out",
isCollapsed
? "max-w-0 opacity-0 ml-0"
: "max-w-[260px] flex-1 opacity-100 ml-2"
)}
>
<span className="block truncate">{label}</span>
</span>
</span>
{!isCollapsed && trailingContent}
{!isCollapsed && badge && typeof badge !== "string" ? badge : null}
{!isCollapsed && badge && typeof badge === "string" ? (
<span className="ml-1 inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">
{badge} {badge}
</span> </span>
) : null} ) : null}
{collapsedOverlay && (
<span
aria-hidden={!isCollapsed}
className={cn(
"pointer-events-none absolute inset-0 transition-opacity duration-150",
isCollapsed ? "opacity-100" : "opacity-0"
)}
>
{collapsedOverlay}
</span>
)}
<span className="sr-only">{label}</span>
</button> </button>
); );
if (!tooltipContent) { const renderTooltip = isCollapsed || !!tooltipContent;
if (!renderTooltip) {
return button; return button;
} }
@ -110,7 +118,14 @@ export function SidebarButton({
<Tooltip> <Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger> <TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs"> <TooltipContent side="right" className="max-w-xs">
{tooltipContent} {isCollapsed
? (tooltipContent ?? (
<>
{label}
{typeof badge === "string" && ` (${badge})`}
</>
))
: tooltipContent}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );