feat: add drag-to-resize sidebar and right panel, improve UI spacing

- Add dynamic width state management for sidebar (200-400px) and right panel (240-480px)
- Implement drag-to-resize functionality with useResize hook and visual feedback
- Persist panel widths to localStorage for consistent UX across sessions
- Increase sidebar toggle padding from pl-20 to pl-24 to avoid macOS traffic lights
- Match "Select a folder to start..." font size to textarea (text-sm)
- Remove redundant close button from RightPanelHeader
- Disable CSS transitions during resize for smoother dragging experience

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Jiayuan
2026-01-15 03:09:45 +08:00
parent 4e8828d159
commit ebff9a5ee7
6 changed files with 184 additions and 25 deletions

View File

@@ -76,7 +76,7 @@ export function MessageInput({
{/* Folder selection prompt */}
<div className="flex items-center gap-3">
<Folder className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<span className="text-muted-foreground flex-1">Select a folder to start...</span>
<span className="text-sm text-muted-foreground flex-1">Select a folder to start...</span>
<Button
variant="outline"
size="sm"

View File

@@ -25,7 +25,7 @@ export function StatusBar({
return (
<div className={cn(
"titlebar-drag-region flex h-11 items-center justify-between px-4",
needsTrafficLightPadding && "pl-20"
needsTrafficLightPadding && "pl-24"
)}>
{/* Left: Sidebar trigger + Session info */}
<div className="titlebar-no-drag flex items-center gap-3">

View File

@@ -6,10 +6,13 @@ import * as React from 'react'
import { PanelLeftIcon, PanelRightIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { useUIStore } from '@/stores/uiStore'
import {
useUIStore,
RIGHT_PANEL_MIN_WIDTH,
RIGHT_PANEL_MAX_WIDTH,
} from '@/stores/uiStore'
import { useSidebar } from '@/components/ui/sidebar'
const RIGHT_PANEL_WIDTH = '20rem' // 320px
import { useResize } from '@/hooks/useResize'
interface RightPanelProps {
children: React.ReactNode
@@ -18,25 +21,47 @@ interface RightPanelProps {
export function RightPanel({ children, className }: RightPanelProps) {
const isOpen = useUIStore((s) => s.rightPanelOpen)
const rightPanelWidth = useUIStore((s) => s.rightPanelWidth)
const setRightPanelWidth = useUIStore((s) => s.setRightPanelWidth)
const { isResizing, handleProps } = useResize({
width: rightPanelWidth,
minWidth: RIGHT_PANEL_MIN_WIDTH,
maxWidth: RIGHT_PANEL_MAX_WIDTH,
onWidthChange: setRightPanelWidth,
direction: 'left',
})
return (
<div
className={cn(
'hidden lg:block',
'transition-[width] duration-200 ease-linear',
// Disable transition during resize for smoother dragging
!isResizing && 'transition-[width] duration-200 ease-linear',
isOpen ? 'w-[var(--right-panel-width)]' : 'w-0'
)}
style={{ '--right-panel-width': RIGHT_PANEL_WIDTH } as React.CSSProperties}
style={{ '--right-panel-width': `${rightPanelWidth}px` } as React.CSSProperties}
>
<div
className={cn(
'fixed inset-y-0 right-0 z-10 h-svh border-l bg-background',
'transition-[transform,opacity] duration-200 ease-linear',
// Disable transition during resize for smoother dragging
!isResizing && 'transition-[transform,opacity,width] duration-200 ease-linear',
isOpen ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0',
className
)}
style={{ width: RIGHT_PANEL_WIDTH }}
style={{ width: `${rightPanelWidth}px` }}
>
{/* Resize handle */}
{isOpen && (
<div
className={cn(
'absolute inset-y-0 left-0 w-1 hover:bg-primary/20 active:bg-primary/30',
isResizing && 'bg-primary/30'
)}
{...handleProps}
/>
)}
{children}
</div>
</div>
@@ -97,23 +122,12 @@ export function RightPanelHeader({
children,
...props
}: React.ComponentProps<'div'>) {
const toggle = useUIStore((s) => s.toggleRightPanel)
return (
<div
className={cn('group flex h-11 items-center border-b px-4', className)}
className={cn('flex h-11 items-center border-b px-4', className)}
{...props}
>
{children}
<Button
variant="ghost"
size="icon"
className="ml-auto size-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={toggle}
>
<PanelRightIcon className="h-4 w-4" />
<span className="sr-only">Close Right Panel</span>
</Button>
</div>
)
}

View File

@@ -6,8 +6,14 @@ import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { useResize } from "@/hooks/useResize"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
useUIStore,
SIDEBAR_MIN_WIDTH,
SIDEBAR_MAX_WIDTH,
} from "@/stores/uiStore"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
@@ -27,7 +33,6 @@ import {
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
@@ -126,6 +131,9 @@ function SidebarProvider({
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
// Get sidebar width from store
const sidebarWidth = useUIStore((s) => s.sidebarWidth)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
@@ -133,7 +141,7 @@ function SidebarProvider({
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width": `${sidebarWidth}px`,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
@@ -165,6 +173,17 @@ function Sidebar({
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
// Resize functionality
const sidebarWidth = useUIStore((s) => s.sidebarWidth)
const setSidebarWidth = useUIStore((s) => s.setSidebarWidth)
const { isResizing, handleProps } = useResize({
width: sidebarWidth,
minWidth: SIDEBAR_MIN_WIDTH,
maxWidth: SIDEBAR_MAX_WIDTH,
onWidthChange: setSidebarWidth,
direction: side === "left" ? "right" : "left",
})
if (collapsible === "none") {
return (
<div
@@ -218,7 +237,9 @@ function Sidebar({
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"relative w-(--sidebar-width) bg-transparent",
// Disable transition during resize for smoother dragging
!isResizing && "transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
@@ -229,7 +250,9 @@ function Sidebar({
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) md:flex",
// Disable transition during resize for smoother dragging
!isResizing && "transition-[left,right,width] duration-200 ease-linear",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
@@ -248,6 +271,18 @@ function Sidebar({
>
{children}
</div>
{/* Resize handle - only show when expanded */}
{state === "expanded" && (
<div
data-slot="sidebar-resize-handle"
className={cn(
"absolute inset-y-0 w-1 hover:bg-primary/20 active:bg-primary/30",
side === "left" ? "right-0" : "left-0",
isResizing && "bg-primary/30"
)}
{...handleProps}
/>
)}
</div>
</div>
)

View File

@@ -0,0 +1,91 @@
/**
* Hook for drag-to-resize functionality
*/
import { useCallback, useEffect, useRef, useState } from 'react'
interface UseResizeOptions {
/** Current width */
width: number
/** Minimum width */
minWidth: number
/** Maximum width */
maxWidth: number
/** Callback when width changes */
onWidthChange: (width: number) => void
/** Direction of resize: 'left' means drag from left edge, 'right' means drag from right edge */
direction: 'left' | 'right'
}
interface UseResizeReturn {
/** Whether currently resizing */
isResizing: boolean
/** Props to spread on the resize handle element */
handleProps: {
onMouseDown: (e: React.MouseEvent) => void
style: React.CSSProperties
}
}
export function useResize({
width,
minWidth,
maxWidth,
onWidthChange,
direction,
}: UseResizeOptions): UseResizeReturn {
const [isResizing, setIsResizing] = useState(false)
const startXRef = useRef(0)
const startWidthRef = useRef(0)
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
setIsResizing(true)
startXRef.current = e.clientX
startWidthRef.current = width
},
[width]
)
useEffect(() => {
if (!isResizing) return
const handleMouseMove = (e: MouseEvent) => {
const delta = e.clientX - startXRef.current
// For left sidebar, dragging right increases width
// For right panel, dragging left increases width
const newWidth =
direction === 'right'
? startWidthRef.current + delta
: startWidthRef.current - delta
const clampedWidth = Math.max(minWidth, Math.min(maxWidth, newWidth))
onWidthChange(clampedWidth)
}
const handleMouseUp = () => {
setIsResizing(false)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
// Add cursor style to body during resize
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
}, [isResizing, minWidth, maxWidth, onWidthChange, direction])
return {
isResizing,
handleProps: {
onMouseDown: handleMouseDown,
style: { cursor: 'col-resize' },
},
}
}

View File

@@ -5,16 +5,29 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
// Width constraints
export const SIDEBAR_MIN_WIDTH = 200
export const SIDEBAR_MAX_WIDTH = 400
export const SIDEBAR_DEFAULT_WIDTH = 256
export const RIGHT_PANEL_MIN_WIDTH = 240
export const RIGHT_PANEL_MAX_WIDTH = 480
export const RIGHT_PANEL_DEFAULT_WIDTH = 320
interface UIStore {
// Sidebar state
sidebarOpen: boolean
sidebarWidth: number
toggleSidebar: () => void
setSidebarOpen: (open: boolean) => void
setSidebarWidth: (width: number) => void
// Right panel state
rightPanelOpen: boolean
rightPanelWidth: number
toggleRightPanel: () => void
setRightPanelOpen: (open: boolean) => void
setRightPanelWidth: (width: number) => void
}
export const useUIStore = create<UIStore>()(
@@ -22,13 +35,19 @@ export const useUIStore = create<UIStore>()(
(set) => ({
// Sidebar - default open
sidebarOpen: true,
sidebarWidth: SIDEBAR_DEFAULT_WIDTH,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setSidebarOpen: (open) => set({ sidebarOpen: open }),
setSidebarWidth: (width) =>
set({ sidebarWidth: Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, width)) }),
// Right panel - default open
rightPanelOpen: true,
rightPanelWidth: RIGHT_PANEL_DEFAULT_WIDTH,
toggleRightPanel: () => set((state) => ({ rightPanelOpen: !state.rightPanelOpen })),
setRightPanelOpen: (open) => set({ rightPanelOpen: open }),
setRightPanelWidth: (width) =>
set({ rightPanelWidth: Math.max(RIGHT_PANEL_MIN_WIDTH, Math.min(RIGHT_PANEL_MAX_WIDTH, width)) }),
}),
{
name: 'ui-state', // localStorage key