From 5932682193d473c7009b47a831986069beead8b7 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sat, 16 May 2026 06:37:13 +0000 Subject: [PATCH] feat(mobile): right-rail as drawer on mobile, header toggle button Reverts v1.6.1's max-md:hidden wrapper around RightRail. On mobile, RightRail now renders as a fixed right-side drawer (w-[85vw], max-w-sm) toggled by a new FolderTree button in the Session header. - New useRightRailDrawer hook mirrors useSidebarDrawer (Context + auto-close on route change). - New MobileRightRailBackdrop component in App.tsx mirrors the existing MobileBackdrop for the left sidebar. - RightRail computes an isOpen synthesis: on mobile, reads the drawer Context; on desktop, reads the persistent internal state. The existing tree-load effect and open_file_in_browser subscription share this plumbing via openRail / closeRail helpers. - The desktop floating chevron handle is hidden on mobile (the Session header's FolderTree button replaces it). - Session header gains a mobile-only FolderTree button after the ModelPicker, calling toggle() on the drawer Context. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/App.tsx | 31 +++++++++++---- apps/web/src/components/RightRail.tsx | 46 +++++++++++++++++++---- apps/web/src/hooks/useRightRailDrawer.tsx | 35 +++++++++++++++++ apps/web/src/pages/Session.tsx | 16 +++++++- 4 files changed, 111 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/hooks/useRightRailDrawer.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 55dcc09..cdc6516 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -9,6 +9,7 @@ import { Session } from '@/pages/Session'; import { Toaster } from '@/components/ui/sonner'; import { useUserEvents } from '@/hooks/useUserEvents'; import { SidebarDrawerProvider, useSidebarDrawer } from '@/hooks/useSidebarDrawer'; +import { RightRailDrawerProvider, useRightRailDrawer } from '@/hooks/useRightRailDrawer'; import { useViewport } from '@/hooks/useViewport'; function SessionRightRail() { @@ -26,13 +27,11 @@ function RightRailForSession({ sessionId }: { sessionId: string }) { .catch((err) => console.warn('RightRail: failed to fetch session', err)); }, [sessionId]); if (!projectId) return null; - // Hidden entirely below md breakpoint; mobile users get the file browser - // via the FileBrowserPane infrastructure if/when it lands in workspace panes. - return ( -
- -
- ); + // v1.6.2: rendered on all viewports. On mobile, RightRail itself renders as + // a right-side drawer toggled by the header's FolderTree button (via + // useRightRailDrawer). On desktop, it renders inline as before with its + // own internal open/close state. + return ; } function MobileBackdrop() { @@ -48,6 +47,19 @@ function MobileBackdrop() { ); } +function MobileRightRailBackdrop() { + const { open, setOpen } = useRightRailDrawer(); + const { isMobile } = useViewport(); + if (!isMobile || !open) return null; + return ( +
setOpen(false)} + aria-hidden="true" + /> + ); +} + function AppShell() { useUserEvents(); return ( @@ -61,6 +73,7 @@ function AppShell() { } /> + } /> @@ -73,7 +86,9 @@ export default function App() { return ( - + + + ); diff --git a/apps/web/src/components/RightRail.tsx b/apps/web/src/components/RightRail.tsx index ecdf026..12f9629 100644 --- a/apps/web/src/components/RightRail.tsx +++ b/apps/web/src/components/RightRail.tsx @@ -4,8 +4,11 @@ import { api } from '@/api/client'; import type { FileEntry } from '@/api/types'; import { inferLanguage } from '@/lib/attachments'; import { sessionEvents } from '@/hooks/sessionEvents'; +import { useRightRailDrawer } from '@/hooks/useRightRailDrawer'; +import { useViewport } from '@/hooks/useViewport'; import { FileViewerOverlay } from '@/components/FileViewerOverlay'; import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; interface Props { projectId: string; @@ -25,6 +28,8 @@ function joinPath(parent: string, name: string): string { } export function RightRail({ projectId }: Props) { + const { isMobile } = useViewport(); + const { open: drawerOpen, setOpen: setDrawerOpen } = useRightRailDrawer(); const [open, setOpen] = useState(() => { try { return localStorage.getItem(`${STORAGE_KEY}.open`) !== 'false'; } catch { return true; } }); @@ -34,6 +39,19 @@ export function RightRail({ projectId }: Props) { const [fullFileList, setFullFileList] = useState(null); const [viewerFile, setViewerFile] = useState<{ path: string; content: string } | null>(null); + // Combined open state: on mobile use the global drawer state (toggled by + // the Session header's FolderTree button); on desktop use the persistent + // internal state. + const isOpen = isMobile ? drawerOpen : open; + const closeRail = useCallback(() => { + if (isMobile) setDrawerOpen(false); + else setOpen(false); + }, [isMobile, setDrawerOpen]); + const openRail = useCallback(() => { + if (isMobile) setDrawerOpen(true); + else setOpen(true); + }, [isMobile, setDrawerOpen]); + useEffect(() => { // best-effort; ignore failure because localStorage may be unavailable (quota, private mode) try { localStorage.setItem(`${STORAGE_KEY}.open`, String(open)); } catch {} @@ -56,9 +74,9 @@ export function RightRail({ projectId }: Props) { }, [projectId]); useEffect(() => { - if (!open) return; + if (!isOpen) return; if (!cache.has('')) void loadDir(''); - }, [open, cache, loadDir]); + }, [isOpen, cache, loadDir]); function toggleDir(dirPath: string) { setExpandedDirs((prev) => { @@ -108,12 +126,14 @@ export function RightRail({ projectId }: Props) { useEffect(() => { return sessionEvents.subscribe((event) => { if (event.type !== 'open_file_in_browser') return; - if (!open) setOpen(true); + if (!isOpen) openRail(); void openFile(event.path); }); - }, [open, projectId]); + }, [isOpen, openRail, projectId]); - if (!open) { + // Desktop closed state: render the floating chevron handle. Mobile never + // shows the handle — the toggle lives in the Session header on mobile. + if (!isMobile && !open) { return (
)} + + {/* File browser toggle — mobile only */} + {isMobile && ( + + )} {id && session && (