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) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 06:37:13 +00:00
parent 9d0d41bcb3
commit 5932682193
4 changed files with 111 additions and 17 deletions

View File

@@ -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 (
<div className="max-md:hidden contents">
<RightRail projectId={projectId} />
</div>
);
// 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 <RightRail projectId={projectId} />;
}
function MobileBackdrop() {
@@ -48,6 +47,19 @@ function MobileBackdrop() {
);
}
function MobileRightRailBackdrop() {
const { open, setOpen } = useRightRailDrawer();
const { isMobile } = useViewport();
if (!isMobile || !open) return null;
return (
<div
className="fixed inset-0 z-30 bg-black/40 md:hidden"
onClick={() => setOpen(false)}
aria-hidden="true"
/>
);
}
function AppShell() {
useUserEvents();
return (
@@ -61,6 +73,7 @@ function AppShell() {
<Route path="/session/:id" element={<Session />} />
</Routes>
</main>
<MobileRightRailBackdrop />
<Routes>
<Route path="/session/:id" element={<SessionRightRail />} />
</Routes>
@@ -73,7 +86,9 @@ export default function App() {
return (
<BrowserRouter>
<SidebarDrawerProvider>
<AppShell />
<RightRailDrawerProvider>
<AppShell />
</RightRailDrawerProvider>
</SidebarDrawerProvider>
</BrowserRouter>
);

View File

@@ -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<string[] | null>(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 (
<button
type="button"
@@ -128,15 +148,25 @@ export function RightRail({ projectId }: Props) {
const rootEntries = cache.get('') ?? [];
// Mobile: render as fixed-position right-side drawer (always mounted so
// the transform transition can animate in/out). Desktop: inline aside.
const asideCls = isMobile
? cn(
'fixed inset-y-0 right-0 z-40 w-[85vw] max-w-sm border-l bg-sidebar flex flex-col overflow-hidden',
'transition-transform duration-200 ease-out',
drawerOpen ? 'translate-x-0' : 'translate-x-full',
)
: 'w-64 shrink-0 border-l bg-sidebar flex flex-col h-full overflow-hidden';
return (
<>
<aside className="w-64 shrink-0 border-l bg-sidebar flex flex-col h-full overflow-hidden">
<aside className={asideCls}>
<div className="flex items-center gap-2 px-3 py-2 border-b shrink-0">
<span className="text-xs font-medium flex-1">Files</span>
<button
type="button"
onClick={() => setOpen(false)}
className="p-1 rounded hover:bg-muted text-muted-foreground"
onClick={closeRail}
className="p-1 rounded hover:bg-muted text-muted-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Close file browser"
>
<PanelRightClose size={14} />

View File

@@ -0,0 +1,35 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { useLocation } from 'react-router-dom';
interface RightRailDrawerState {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
}
const Ctx = createContext<RightRailDrawerState | null>(null);
export function RightRailDrawerProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const location = useLocation();
// Auto-close on route change. Same pattern as useSidebarDrawer — keeps the
// drawer from leaking between sessions when the user navigates.
useEffect(() => {
setOpen(false);
}, [location.pathname]);
const toggle = useCallback(() => setOpen((v) => !v), []);
return <Ctx.Provider value={{ open, setOpen, toggle }}>{children}</Ctx.Provider>;
}
export function useRightRailDrawer(): RightRailDrawerState {
const ctx = useContext(Ctx);
if (!ctx) {
// Soft fallback so consumers don't crash if rendered outside a provider.
return { open: false, setOpen: () => {}, toggle: () => {} };
}
return ctx;
}

View File

@@ -1,11 +1,12 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { ChevronRight, Menu } from 'lucide-react';
import { ChevronRight, FolderTree, Menu } from 'lucide-react';
import { api } from '@/api/client';
import type { Project, Session as SessionType } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
import { useActivePane } from '@/hooks/useActivePane';
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport';
import { Workspace } from '@/components/Workspace';
import { ModelPicker } from '@/components/ModelPicker';
@@ -19,6 +20,7 @@ export function Session() {
const [editingName, setEditingName] = useState(false);
const active = useActivePane();
const { setOpen: setDrawerOpen } = useSidebarDrawer();
const { toggle: toggleRightRail } = useRightRailDrawer();
const { isMobile } = useViewport();
useEffect(() => {
@@ -176,6 +178,18 @@ export function Session() {
</div>
)}
</div>
{/* File browser toggle — mobile only */}
{isMobile && (
<button
type="button"
onClick={toggleRightRail}
className="inline-flex items-center justify-center -mr-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
aria-label="Toggle file browser"
>
<FolderTree className="size-5" />
</button>
)}
</header>
{id && session && (