@@ -20,6 +20,7 @@ interface SidebarContainerProps {
2020 role : string ;
2121 "aria-label" : string ;
2222}
23+ const MOBILE_DEFAULT_WIDTH = 360 ;
2324
2425/**
2526 * SidebarContainer - Main sidebar wrapper with dynamic width
@@ -30,42 +31,40 @@ interface SidebarContainerProps {
3031 * 3. wide - Auto-calculated max width for Review tab (when not resizing)
3132 * 4. default (300px) - Costs/Tools tabs
3233 */
33- const SidebarContainer : React . FC < SidebarContainerProps > = ( {
34- collapsed,
35- wide,
36- customWidth,
37- children,
38- role,
39- "aria-label" : ariaLabel ,
40- } ) => {
41- const width = collapsed
42- ? "20px"
43- : customWidth
44- ? `${ customWidth } px`
45- : wide
46- ? "min(1200px, calc(100vw - 400px))"
47- : "300px" ;
34+ const SidebarContainer = React . forwardRef < HTMLDivElement , SidebarContainerProps > (
35+ ( { collapsed, wide, customWidth, children, role, "aria-label" : ariaLabel } , ref ) => {
36+ const width = collapsed
37+ ? "20px"
38+ : customWidth
39+ ? `${ customWidth } px`
40+ : wide
41+ ? "min(1200px, calc(100vw - 400px))"
42+ : "300px" ;
43+
44+ return (
45+ < div
46+ ref = { ref }
47+ className = { cn (
48+ "bg-separator border-l border-border-light flex flex-col overflow-hidden flex-shrink-0" ,
49+ customWidth ? "" : "transition-[width] duration-200" ,
50+ collapsed && "sticky right-0 z-10 shadow-[-2px_0_4px_rgba(0,0,0,0.2)]" ,
51+ // Mobile: slide in from right (similar to left sidebar pattern)
52+ "max-md:fixed max-md:right-0 max-md:top-0 max-md:h-screen max-md:transition-transform max-md:duration-300" ,
53+ collapsed && "max-md:translate-x-full max-md:shadow-none" ,
54+ ! collapsed &&
55+ "max-md:translate-x-0 max-md:w-full max-md:max-w-md max-md:z-[999] max-md:shadow-[-2px_0_8px_rgba(0,0,0,0.5)] max-md:border-l max-md:border-border-light"
56+ ) }
57+ style = { { width } }
58+ role = { role }
59+ aria-label = { ariaLabel }
60+ >
61+ { children }
62+ </ div >
63+ ) ;
64+ }
65+ ) ;
4866
49- return (
50- < div
51- className = { cn (
52- "bg-separator border-l border-border-light flex flex-col overflow-hidden flex-shrink-0" ,
53- customWidth ? "" : "transition-[width] duration-200" ,
54- collapsed && "sticky right-0 z-10 shadow-[-2px_0_4px_rgba(0,0,0,0.2)]" ,
55- // Mobile: slide in from right (similar to left sidebar pattern)
56- "max-md:fixed max-md:right-0 max-md:top-0 max-md:h-screen max-md:transition-transform max-md:duration-300" ,
57- collapsed && "max-md:translate-x-full max-md:shadow-none" ,
58- ! collapsed &&
59- "max-md:translate-x-0 max-md:w-full max-md:max-w-md max-md:z-[999] max-md:shadow-[-2px_0_8px_rgba(0,0,0,0.5)] max-md:border-l max-md:border-border-light"
60- ) }
61- style = { { width } }
62- role = { role }
63- aria-label = { ariaLabel }
64- >
65- { children }
66- </ div >
67- ) ;
68- } ;
67+ SidebarContainer . displayName = "SidebarContainer" ;
6968
7069type TabType = "costs" | "review" ;
7170
@@ -174,6 +173,9 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
174173
175174 // Track manual expansion to prevent auto-collapse immediately after user opens sidebar
176175 const manualExpandRef = React . useRef ( false ) ;
176+ const sidebarRef = React . useRef < HTMLDivElement | null > ( null ) ;
177+ const [ measuredSidebarWidth , setMeasuredSidebarWidth ] = React . useState < number > ( 0 ) ;
178+ const [ viewportWidth , setViewportWidth ] = React . useState < number > ( 0 ) ;
177179
178180 const openSidebar = React . useCallback (
179181 ( manual : boolean ) => {
@@ -226,6 +228,68 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
226228 } , [ chatAreaWidth , selectedTab , showCollapsed , closeSidebar , openSidebarAuto ] ) ;
227229
228230 // Swipe gesture detection for mobile - right-to-left swipe to open sidebar
231+ React . useEffect ( ( ) => {
232+ if ( typeof window === "undefined" ) {
233+ return ;
234+ }
235+
236+ const updateViewportWidth = ( ) => {
237+ setViewportWidth ( window . innerWidth ) ;
238+ } ;
239+
240+ updateViewportWidth ( ) ;
241+ window . addEventListener ( "resize" , updateViewportWidth ) ;
242+ return ( ) => window . removeEventListener ( "resize" , updateViewportWidth ) ;
243+ } , [ ] ) ;
244+
245+ React . useEffect ( ( ) => {
246+ const element = sidebarRef . current ;
247+ if ( ! element ) {
248+ return ;
249+ }
250+
251+ const updateMeasuredWidth = ( ) => {
252+ setMeasuredSidebarWidth ( element . getBoundingClientRect ( ) . width ) ;
253+ } ;
254+
255+ updateMeasuredWidth ( ) ;
256+
257+ if ( typeof ResizeObserver === "undefined" ) {
258+ return ;
259+ }
260+
261+ const observer = new ResizeObserver ( ( entries ) => {
262+ for ( const entry of entries ) {
263+ setMeasuredSidebarWidth ( entry . contentRect . width ) ;
264+ }
265+ } ) ;
266+
267+ observer . observe ( element ) ;
268+ return ( ) => observer . disconnect ( ) ;
269+ } , [ showCollapsed , selectedTab , width ] ) ;
270+
271+ const effectiveSidebarWidth = React . useMemo ( ( ) => {
272+ if ( ! viewportWidth ) {
273+ return null ;
274+ }
275+
276+ const candidate =
277+ ( measuredSidebarWidth > 0 ? measuredSidebarWidth : undefined ) ??
278+ ( typeof width === "number" && width > 0 ? width : undefined ) ??
279+ MOBILE_DEFAULT_WIDTH ;
280+ const sanitized = candidate > 0 ? candidate : MOBILE_DEFAULT_WIDTH ;
281+ return Math . min ( sanitized , viewportWidth ) ;
282+ } , [ measuredSidebarWidth , width , viewportWidth ] ) ;
283+
284+ const overlayClickableWidth = React . useMemo ( ( ) => {
285+ if ( showCollapsed || ! viewportWidth ) {
286+ return 0 ;
287+ }
288+
289+ const sidebarWidthForCalc = effectiveSidebarWidth ?? MOBILE_DEFAULT_WIDTH ;
290+ return Math . max ( viewportWidth - sidebarWidthForCalc , 0 ) ;
291+ } , [ effectiveSidebarWidth , showCollapsed , viewportWidth ] ) ;
292+
229293 React . useEffect ( ( ) => {
230294 // Only enable swipe on mobile when sidebar is collapsed
231295 if ( typeof window === "undefined" ) return ;
@@ -292,14 +356,22 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
292356 < >
293357 { /* Backdrop overlay - only on mobile when sidebar is expanded */ }
294358 { ! showCollapsed && (
295- < div
296- className = "fixed inset-0 z-[998] hidden bg-black/50 backdrop-blur-sm max-md:block"
297- onClick = { closeSidebar }
298- aria-hidden = "true"
299- />
359+ < div className = "fixed inset-0 z-[998] hidden max-md:block" aria-hidden = "true" >
360+ < div className = "pointer-events-none absolute inset-0 bg-black/50 backdrop-blur-sm" />
361+ { overlayClickableWidth > 1 && (
362+ < button
363+ type = "button"
364+ className = "absolute top-0 left-0 h-full bg-transparent"
365+ style = { { width : overlayClickableWidth } }
366+ onClick = { closeSidebar }
367+ aria-label = "Close review panel"
368+ />
369+ ) }
370+ </ div >
300371 ) }
301372
302373 < SidebarContainer
374+ ref = { sidebarRef }
303375 collapsed = { showCollapsed }
304376 wide = { selectedTab === "review" && ! width } // Auto-wide only if not drag-resizing
305377 customWidth = { width } // Drag-resized width from AIView (Review tab only)
0 commit comments