diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index 13991ffeb..57e386a91 100644 --- a/src/components/AIView.tsx +++ b/src/components/AIView.tsx @@ -102,6 +102,12 @@ const AIViewInner: React.FC = ({ chatInputAPI.current?.appendText(note); }, []); + // Ref to store the sidebar open function (for mobile header button) + const openRightSidebarRef = useRef<(() => void) | null>(null); + const handleOpenRightSidebar = useCallback(() => { + openRightSidebarRef.current?.(); + }, []); + // Thinking level state from context const { thinkingLevel: currentWorkspaceThinking, setThinkingLevel } = useThinking(); @@ -328,6 +334,7 @@ const AIViewInner: React.FC = ({ branch={branch} namedWorkspacePath={namedWorkspacePath} runtimeConfig={runtimeConfig} + onOpenRightSidebar={handleOpenRightSidebar} />
@@ -482,6 +489,9 @@ const AIViewInner: React.FC = ({ onStartResize={isReviewTabActive ? startResize : undefined} // Pass resize handler when Review active isResizing={isResizing} // Pass resizing state onReviewNote={handleReviewNote} // Pass review note handler to append to chat + onMountOpenCallback={(openFn) => { + openRightSidebarRef.current = openFn; + }} />
); diff --git a/src/components/RightSidebar.tsx b/src/components/RightSidebar.tsx index 3eb7cd7a5..404e00ace 100644 --- a/src/components/RightSidebar.tsx +++ b/src/components/RightSidebar.tsx @@ -20,6 +20,7 @@ interface SidebarContainerProps { role: string; "aria-label": string; } +const MOBILE_DEFAULT_WIDTH = 360; /** * SidebarContainer - Main sidebar wrapper with dynamic width @@ -30,40 +31,40 @@ interface SidebarContainerProps { * 3. wide - Auto-calculated max width for Review tab (when not resizing) * 4. default (300px) - Costs/Tools tabs */ -const SidebarContainer: React.FC = ({ - collapsed, - wide, - customWidth, - children, - role, - "aria-label": ariaLabel, -}) => { - const width = collapsed - ? "20px" - : customWidth - ? `${customWidth}px` - : wide - ? "min(1200px, calc(100vw - 400px))" - : "300px"; +const SidebarContainer = React.forwardRef( + ({ collapsed, wide, customWidth, children, role, "aria-label": ariaLabel }, ref) => { + const width = collapsed + ? "20px" + : customWidth + ? `${customWidth}px` + : wide + ? "min(1200px, calc(100vw - 400px))" + : "300px"; - return ( -
- {children} -
- ); -}; + return ( +
+ {children} +
+ ); + } +); + +SidebarContainer.displayName = "SidebarContainer"; type TabType = "costs" | "review"; @@ -83,6 +84,8 @@ interface RightSidebarProps { isResizing?: boolean; /** Callback when user adds a review note from Code Review tab */ onReviewNote?: (note: string) => void; + /** Callback to expose the open sidebar function (for mobile header button) */ + onMountOpenCallback?: (openFn: () => void) => void; } const RightSidebarComponent: React.FC = ({ @@ -94,6 +97,7 @@ const RightSidebarComponent: React.FC = ({ onStartResize, isResizing = false, onReviewNote, + onMountOpenCallback, }) => { // Global tab preference (not per-workspace) const [selectedTab, setSelectedTab] = usePersistedState("right-sidebar-tab", "costs"); @@ -162,136 +166,356 @@ const RightSidebarComponent: React.FC = ({ false ); + // Single render point for VerticalTokenMeter + // Shows when: (1) collapsed, OR (2) Review tab is active + const showMeter = showCollapsed || selectedTab === "review"; + const verticalMeter = showMeter ? : null; + + // Track manual expansion to prevent auto-collapse immediately after user opens sidebar + const manualExpandRef = React.useRef(false); + const manualCollapseRef = React.useRef(false); + const sidebarRef = React.useRef(null); + const [measuredSidebarWidth, setMeasuredSidebarWidth] = React.useState(0); + const [viewportWidth, setViewportWidth] = React.useState(0); + + const openSidebar = React.useCallback( + (manual: boolean) => { + manualCollapseRef.current = false; + manualExpandRef.current = manual; + setShowCollapsed(false); + }, + [setShowCollapsed] + ); + + const closeSidebar = React.useCallback( + (manual: boolean) => { + if (manual) { + manualCollapseRef.current = true; + } + manualExpandRef.current = false; + setShowCollapsed(true); + }, + [setShowCollapsed] + ); + + // Expose open function to parent (for mobile header button) + React.useEffect(() => { + if (onMountOpenCallback) { + onMountOpenCallback(() => openSidebar(true)); + } + }, [onMountOpenCallback, openSidebar]); + + const openSidebarAuto = React.useCallback(() => openSidebar(false), [openSidebar]); + const openSidebarManual = React.useCallback(() => openSidebar(true), [openSidebar]); + React.useEffect(() => { // Never collapse when Review tab is active - code review needs space if (selectedTab === "review") { + if (manualCollapseRef.current) { + return; + } + if (showCollapsed) { - setShowCollapsed(false); + openSidebarAuto(); } + manualExpandRef.current = false; + return; + } + + // Reset manual collapse guard once user leaves the review tab + manualCollapseRef.current = false; + + // If user manually expanded on mobile, keep sidebar open until they close it + if (manualExpandRef.current) { return; } - // Normal hysteresis for Costs/Tools tabs if (chatAreaWidth <= COLLAPSE_THRESHOLD) { - setShowCollapsed(true); + if (!showCollapsed) { + closeSidebar(false); + } } else if (chatAreaWidth >= EXPAND_THRESHOLD) { - setShowCollapsed(false); + if (showCollapsed) { + openSidebarAuto(); + } } // Between thresholds: maintain current state (no change) - }, [chatAreaWidth, selectedTab, showCollapsed, setShowCollapsed]); + }, [chatAreaWidth, selectedTab, showCollapsed, closeSidebar, openSidebarAuto]); - // Single render point for VerticalTokenMeter - // Shows when: (1) collapsed, OR (2) Review tab is active - const showMeter = showCollapsed || selectedTab === "review"; - const verticalMeter = showMeter ? : null; + // Swipe gesture detection for mobile - right-to-left swipe to open sidebar + React.useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const updateViewportWidth = () => { + setViewportWidth(window.innerWidth); + }; + + updateViewportWidth(); + window.addEventListener("resize", updateViewportWidth); + return () => window.removeEventListener("resize", updateViewportWidth); + }, []); + + React.useEffect(() => { + const element = sidebarRef.current; + if (!element) { + return; + } + + const updateMeasuredWidth = () => { + setMeasuredSidebarWidth(element.getBoundingClientRect().width); + }; + + updateMeasuredWidth(); + + if (typeof ResizeObserver === "undefined") { + return; + } + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setMeasuredSidebarWidth(entry.contentRect.width); + } + }); + + observer.observe(element); + return () => observer.disconnect(); + }, [showCollapsed, selectedTab, width]); + + const effectiveSidebarWidth = React.useMemo(() => { + if (!viewportWidth) { + return null; + } + + const candidate = + (measuredSidebarWidth > 0 ? measuredSidebarWidth : undefined) ?? + (typeof width === "number" && width > 0 ? width : undefined) ?? + MOBILE_DEFAULT_WIDTH; + const sanitized = candidate > 0 ? candidate : MOBILE_DEFAULT_WIDTH; + return Math.min(sanitized, viewportWidth); + }, [measuredSidebarWidth, width, viewportWidth]); + + const overlayClickableWidth = React.useMemo(() => { + if (showCollapsed || !viewportWidth) { + return 0; + } + + const sidebarWidthForCalc = effectiveSidebarWidth ?? MOBILE_DEFAULT_WIDTH; + return Math.max(viewportWidth - sidebarWidthForCalc, 0); + }, [effectiveSidebarWidth, showCollapsed, viewportWidth]); + + React.useEffect(() => { + // Only enable swipe on mobile when sidebar is collapsed + if (typeof window === "undefined") return; + + let touchStartX = 0; + let touchStartY = 0; + let touchStartTime = 0; + + const handleTouchStart = (e: TouchEvent) => { + // Only detect swipes from right edge (last ~50px of screen) + const touch = e.touches[0]; + if (!touch) return; + + const screenWidth = window.innerWidth; + if (touch.clientX < screenWidth - 50) return; // Not from right edge + + touchStartX = touch.clientX; + touchStartY = touch.clientY; + touchStartTime = Date.now(); + }; + + const handleTouchEnd = (e: TouchEvent) => { + const touch = e.changedTouches[0]; + if (!touch) return; + + const touchEndX = touch.clientX; + const touchEndY = touch.clientY; + const touchEndTime = Date.now(); + + // Calculate swipe distance and direction + const deltaX = touchEndX - touchStartX; + const deltaY = touchEndY - touchStartY; + const duration = touchEndTime - touchStartTime; + + // Swipe must be: + // 1. Horizontal (more X movement than Y) + // 2. At least 50px distance + // 3. Fast enough (< 300ms) + const isLeftSwipe = deltaX < -50; // Right to left + const isRightSwipe = deltaX > 50; // Left to right + const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY); + const isFastEnough = duration < 300; + + // Open sidebar on left swipe from right edge when collapsed + if (isLeftSwipe && isHorizontal && isFastEnough && showCollapsed) { + openSidebarManual(); + } + // Close sidebar on right swipe when open (from anywhere on screen) + else if (isRightSwipe && isHorizontal && isFastEnough && !showCollapsed) { + closeSidebar(true); + } + }; + + window.addEventListener("touchstart", handleTouchStart, { passive: true }); + window.addEventListener("touchend", handleTouchEnd, { passive: true }); + + return () => { + window.removeEventListener("touchstart", handleTouchStart); + window.removeEventListener("touchend", handleTouchEnd); + }; + }, [showCollapsed, closeSidebar, openSidebarManual]); return ( - - {/* Full view when not collapsed */} -
- {/* Render meter when Review tab is active */} - {selectedTab === "review" && ( -
- {verticalMeter} -
- )} + <> + {/* Backdrop overlay - only on mobile when sidebar is expanded */} + {!showCollapsed && ( +