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 { Toaster } from '@/components/ui/sonner';
import { useUserEvents } from '@/hooks/useUserEvents'; import { useUserEvents } from '@/hooks/useUserEvents';
import { SidebarDrawerProvider, useSidebarDrawer } from '@/hooks/useSidebarDrawer'; import { SidebarDrawerProvider, useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { RightRailDrawerProvider, useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport'; import { useViewport } from '@/hooks/useViewport';
function SessionRightRail() { function SessionRightRail() {
@@ -26,13 +27,11 @@ function RightRailForSession({ sessionId }: { sessionId: string }) {
.catch((err) => console.warn('RightRail: failed to fetch session', err)); .catch((err) => console.warn('RightRail: failed to fetch session', err));
}, [sessionId]); }, [sessionId]);
if (!projectId) return null; if (!projectId) return null;
// Hidden entirely below md breakpoint; mobile users get the file browser // v1.6.2: rendered on all viewports. On mobile, RightRail itself renders as
// via the FileBrowserPane infrastructure if/when it lands in workspace panes. // a right-side drawer toggled by the header's FolderTree button (via
return ( // useRightRailDrawer). On desktop, it renders inline as before with its
<div className="max-md:hidden contents"> // own internal open/close state.
<RightRail projectId={projectId} /> return <RightRail projectId={projectId} />;
</div>
);
} }
function MobileBackdrop() { 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() { function AppShell() {
useUserEvents(); useUserEvents();
return ( return (
@@ -61,6 +73,7 @@ function AppShell() {
<Route path="/session/:id" element={<Session />} /> <Route path="/session/:id" element={<Session />} />
</Routes> </Routes>
</main> </main>
<MobileRightRailBackdrop />
<Routes> <Routes>
<Route path="/session/:id" element={<SessionRightRail />} /> <Route path="/session/:id" element={<SessionRightRail />} />
</Routes> </Routes>
@@ -73,7 +86,9 @@ export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<SidebarDrawerProvider> <SidebarDrawerProvider>
<AppShell /> <RightRailDrawerProvider>
<AppShell />
</RightRailDrawerProvider>
</SidebarDrawerProvider> </SidebarDrawerProvider>
</BrowserRouter> </BrowserRouter>
); );

View File

@@ -4,8 +4,11 @@ import { api } from '@/api/client';
import type { FileEntry } from '@/api/types'; import type { FileEntry } from '@/api/types';
import { inferLanguage } from '@/lib/attachments'; import { inferLanguage } from '@/lib/attachments';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport';
import { FileViewerOverlay } from '@/components/FileViewerOverlay'; import { FileViewerOverlay } from '@/components/FileViewerOverlay';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
interface Props { interface Props {
projectId: string; projectId: string;
@@ -25,6 +28,8 @@ function joinPath(parent: string, name: string): string {
} }
export function RightRail({ projectId }: Props) { export function RightRail({ projectId }: Props) {
const { isMobile } = useViewport();
const { open: drawerOpen, setOpen: setDrawerOpen } = useRightRailDrawer();
const [open, setOpen] = useState(() => { const [open, setOpen] = useState(() => {
try { return localStorage.getItem(`${STORAGE_KEY}.open`) !== 'false'; } catch { return true; } 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 [fullFileList, setFullFileList] = useState<string[] | null>(null);
const [viewerFile, setViewerFile] = useState<{ path: string; content: 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(() => { useEffect(() => {
// best-effort; ignore failure because localStorage may be unavailable (quota, private mode) // best-effort; ignore failure because localStorage may be unavailable (quota, private mode)
try { localStorage.setItem(`${STORAGE_KEY}.open`, String(open)); } catch {} try { localStorage.setItem(`${STORAGE_KEY}.open`, String(open)); } catch {}
@@ -56,9 +74,9 @@ export function RightRail({ projectId }: Props) {
}, [projectId]); }, [projectId]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!isOpen) return;
if (!cache.has('')) void loadDir(''); if (!cache.has('')) void loadDir('');
}, [open, cache, loadDir]); }, [isOpen, cache, loadDir]);
function toggleDir(dirPath: string) { function toggleDir(dirPath: string) {
setExpandedDirs((prev) => { setExpandedDirs((prev) => {
@@ -108,12 +126,14 @@ export function RightRail({ projectId }: Props) {
useEffect(() => { useEffect(() => {
return sessionEvents.subscribe((event) => { return sessionEvents.subscribe((event) => {
if (event.type !== 'open_file_in_browser') return; if (event.type !== 'open_file_in_browser') return;
if (!open) setOpen(true); if (!isOpen) openRail();
void openFile(event.path); 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 ( return (
<button <button
type="button" type="button"
@@ -128,15 +148,25 @@ export function RightRail({ projectId }: Props) {
const rootEntries = cache.get('') ?? []; 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 ( 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"> <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> <span className="text-xs font-medium flex-1">Files</span>
<button <button
type="button" type="button"
onClick={() => setOpen(false)} onClick={closeRail}
className="p-1 rounded hover:bg-muted text-muted-foreground" 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" aria-label="Close file browser"
> >
<PanelRightClose size={14} /> <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 { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom'; 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 { api } from '@/api/client';
import type { Project, Session as SessionType } from '@/api/types'; import type { Project, Session as SessionType } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
import { useActivePane } from '@/hooks/useActivePane'; import { useActivePane } from '@/hooks/useActivePane';
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer'; import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport'; import { useViewport } from '@/hooks/useViewport';
import { Workspace } from '@/components/Workspace'; import { Workspace } from '@/components/Workspace';
import { ModelPicker } from '@/components/ModelPicker'; import { ModelPicker } from '@/components/ModelPicker';
@@ -19,6 +20,7 @@ export function Session() {
const [editingName, setEditingName] = useState(false); const [editingName, setEditingName] = useState(false);
const active = useActivePane(); const active = useActivePane();
const { setOpen: setDrawerOpen } = useSidebarDrawer(); const { setOpen: setDrawerOpen } = useSidebarDrawer();
const { toggle: toggleRightRail } = useRightRailDrawer();
const { isMobile } = useViewport(); const { isMobile } = useViewport();
useEffect(() => { useEffect(() => {
@@ -176,6 +178,18 @@ export function Session() {
</div> </div>
)} )}
</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> </header>
{id && session && ( {id && session && (