feat(mobile): viewport hook + sidebar drawer + hamburger headers

- useViewport: matchMedia-based hook (no resize polling). Breakpoints
  mobile <768 / tablet 768-1023 / desktop >=1024. SSR-safe.
- useSidebarDrawer: Context provider with open/setOpen/toggle + auto-close
  on useLocation().pathname change.
- App.tsx: wraps SidebarDrawerProvider around AppShell, renders a
  MobileBackdrop (z-30) when the drawer is open on mobile.
- ProjectSidebar: aside is fixed/translate-x-full off-screen on mobile,
  slides in (z-40, 200ms transform) when drawerOpen. Inline column on
  desktop, unchanged.
- Session.tsx + Project.tsx: hamburger (Menu icon, >=44x44 min) on mobile
  opens the drawer. Headers gain paddingTop: max(0.75rem,
  env(safe-area-inset-top)) for notch devices. Home.tsx left alone
  (sidebar content duplicates the home page).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 05:54:33 +00:00
parent 57c883b775
commit a643b5f67f
6 changed files with 162 additions and 13 deletions

View File

@@ -8,6 +8,8 @@ import { Project } from '@/pages/Project';
import { Session } from '@/pages/Session';
import { Toaster } from '@/components/ui/sonner';
import { useUserEvents } from '@/hooks/useUserEvents';
import { SidebarDrawerProvider, useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { useViewport } from '@/hooks/useViewport';
function SessionRightRail() {
const { id } = useParams<{ id: string }>();
@@ -27,11 +29,25 @@ function RightRailForSession({ sessionId }: { sessionId: string }) {
return <RightRail projectId={projectId} />;
}
function MobileBackdrop() {
const { open, setOpen } = useSidebarDrawer();
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 (
<div className="dark h-screen flex bg-background text-foreground">
<ProjectSidebar />
<MobileBackdrop />
<main className="flex-1 flex flex-col min-w-0">
<Routes>
<Route path="/" element={<Home />} />
@@ -50,7 +66,9 @@ function AppShell() {
export default function App() {
return (
<BrowserRouter>
<AppShell />
<SidebarDrawerProvider>
<AppShell />
</SidebarDrawerProvider>
</BrowserRouter>
);
}

View File

@@ -20,6 +20,8 @@ import {
import { AddProjectModal } from './AddProjectModal';
import { api } from '@/api/client';
import { useSidebar } from '@/hooks/useSidebar';
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { useViewport } from '@/hooks/useViewport';
import type { SidebarProject } from '@/api/types';
import { giteaUrlFor } from '@/lib/projectUrls';
import { cn } from '@/lib/utils';
@@ -195,8 +197,23 @@ export function ProjectSidebar() {
const rowCls = (active: boolean) =>
active ? 'bg-sidebar-accent text-sidebar-accent-foreground' : 'hover:bg-sidebar-accent/60';
const { open: drawerOpen } = useSidebarDrawer();
const { isMobile } = useViewport();
// On mobile the sidebar is a slide-in drawer (fixed, z-40, off-screen by
// default). On desktop it sits inline as a normal flex column. The
// backdrop is rendered by AppShell; drawer-open state lives in
// SidebarDrawerProvider.
const asideCls = isMobile
? cn(
'fixed inset-y-0 left-0 z-40 w-60 border-r bg-sidebar text-sidebar-foreground flex flex-col',
'transition-transform duration-200 ease-out',
drawerOpen ? 'translate-x-0' : '-translate-x-full',
)
: 'w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen';
return (
<aside className="w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen">
<aside className={asideCls}>
<div className="px-4 py-3 border-b flex items-center justify-between">
<NavLink to="/" className="font-semibold tracking-tight text-base">
BooCode

View File

@@ -0,0 +1,36 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { useLocation } from 'react-router-dom';
interface SidebarDrawerState {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
}
const Ctx = createContext<SidebarDrawerState | null>(null);
export function SidebarDrawerProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const location = useLocation();
// Auto-close on navigation. Effect fires once on mount too (open default
// is false, so no observable effect) and on every pathname change after.
useEffect(() => {
setOpen(false);
}, [location.pathname]);
const toggle = useCallback(() => setOpen((v) => !v), []);
return <Ctx.Provider value={{ open, setOpen, toggle }}>{children}</Ctx.Provider>;
}
export function useSidebarDrawer(): SidebarDrawerState {
const ctx = useContext(Ctx);
if (!ctx) {
// Soft fallback so consumers don't crash if rendered outside a provider.
// In practice all top-level routes are inside the provider.
return { open: false, setOpen: () => {}, toggle: () => {} };
}
return ctx;
}

View File

@@ -0,0 +1,45 @@
import { useEffect, useState } from 'react';
// Breakpoints (px): mobile <768, tablet 768-1023, desktop >=1024.
const MOBILE_MAX = 767;
const TABLET_MAX = 1023;
export interface ViewportSnapshot {
isMobile: boolean;
isTablet: boolean;
width: number;
}
function snapshot(): ViewportSnapshot {
if (typeof window === 'undefined') {
return { isMobile: false, isTablet: false, width: 1280 };
}
const width = window.innerWidth;
return {
isMobile: width <= MOBILE_MAX,
isTablet: width > MOBILE_MAX && width <= TABLET_MAX,
width,
};
}
// matchMedia-based, no resize polling. We listen to two breakpoint queries
// and recompute the snapshot on any change.
export function useViewport(): ViewportSnapshot {
const [state, setState] = useState<ViewportSnapshot>(snapshot);
useEffect(() => {
if (typeof window === 'undefined') return;
const mobileMq = window.matchMedia(`(max-width: ${MOBILE_MAX}px)`);
const tabletMq = window.matchMedia(`(min-width: ${MOBILE_MAX + 1}px) and (max-width: ${TABLET_MAX}px)`);
const update = () => setState(snapshot());
mobileMq.addEventListener('change', update);
tabletMq.addEventListener('change', update);
update();
return () => {
mobileMq.removeEventListener('change', update);
tabletMq.removeEventListener('change', update);
};
}, []);
return state;
}

View File

@@ -1,12 +1,14 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { Plus, MessageSquare, Trash2, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react';
import { Plus, MessageSquare, Trash2, ChevronDown, ChevronRight, RotateCcw, Menu } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { Project as ProjectType, Session } from '@/api/types';
import { Button } from '@/components/ui/button';
import { sessionEvents } from '@/hooks/sessionEvents';
import { useSessions } from '@/hooks/useSessions';
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { useViewport } from '@/hooks/useViewport';
export function Project() {
const { id } = useParams<{ id: string }>();
@@ -16,6 +18,8 @@ export function Project() {
const [creating, setCreating] = useState(false);
const [archivedSessions, setArchivedSessions] = useState<Session[] | null>(null);
const [showArchived, setShowArchived] = useState(false);
const { setOpen: setDrawerOpen } = useSidebarDrawer();
const { isMobile } = useViewport();
useEffect(() => {
if (!id) return;
@@ -76,16 +80,31 @@ export function Project() {
return (
<div className="flex-1 flex flex-col">
<header className="border-b px-6 py-3 flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold tracking-tight">
{project?.name ?? '…'}
</h1>
<div className="text-xs text-muted-foreground font-mono">
{project?.path}
<header
className="border-b px-6 py-3 flex items-center justify-between gap-2"
style={{ paddingTop: 'max(0.75rem, env(safe-area-inset-top))' }}
>
<div className="flex items-center gap-2 min-w-0">
{isMobile && (
<button
type="button"
onClick={() => setDrawerOpen(true)}
className="inline-flex items-center justify-center -ml-2 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
aria-label="Open sidebar"
>
<Menu className="size-5" />
</button>
)}
<div className="min-w-0">
<h1 className="text-lg font-semibold tracking-tight truncate">
{project?.name ?? '…'}
</h1>
<div className="text-xs text-muted-foreground font-mono truncate">
{project?.path}
</div>
</div>
</div>
<Button onClick={handleNew} disabled={creating}>
<Button onClick={handleNew} disabled={creating} className="shrink-0">
<Plus />
New session
</Button>

View File

@@ -1,10 +1,12 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { ChevronRight } from 'lucide-react';
import { ChevronRight, 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 { useViewport } from '@/hooks/useViewport';
import { Workspace } from '@/components/Workspace';
import { ModelPicker } from '@/components/ModelPicker';
@@ -16,6 +18,8 @@ export function Session() {
const [name, setName] = useState('');
const [editingName, setEditingName] = useState(false);
const active = useActivePane();
const { setOpen: setDrawerOpen } = useSidebarDrawer();
const { isMobile } = useViewport();
useEffect(() => {
if (!id) return;
@@ -83,7 +87,17 @@ export function Session() {
return (
<div className="flex-1 flex flex-col min-h-0">
<header className="border-b px-4 py-2 flex items-center gap-1.5 shrink-0 text-sm">
<header className="border-b px-4 py-2 flex items-center gap-1.5 shrink-0 text-sm" style={{ paddingTop: 'max(0.5rem, env(safe-area-inset-top))' }}>
{isMobile && (
<button
type="button"
onClick={() => setDrawerOpen(true)}
className="inline-flex items-center justify-center -ml-1 mr-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Open sidebar"
>
<Menu className="size-5" />
</button>
)}
<Link to="/" className="text-muted-foreground hover:text-foreground">
Projects
</Link>