"use client"; import * as React from "react"; import { usePathname, useRouter } from "next/navigation"; import { PanelLeft } from "lucide-react"; import { useApp } from "./store"; import { Sidebar } from "./sidebar"; import { CommandPalette } from "./command-palette"; import { BottomNav } from "./bottom-nav"; import { Button } from "@/components/ui/button"; import { useIsMobile, useKeyboardInset, useLocalStorage, usePrefersReducedMotion, useSwipe } from "@/lib/client/hooks"; import { cn } from "@/lib/utils"; const LS_COLLAPSED = "polyllm:sidebar-collapsed"; /** * Application shell. * * Desktop (≥ md): persistent left sidebar (collapsible, ⌘B) + content. * Phone: full-viewport content (`.h-app` tracks the keyboard), a slide-over conversation drawer * (edge-swipe to open, swipe left / tap scrim to close) and a persistent bottom navigation. */ export function AppShell({ children }: { children: React.ReactNode }) { const { sidebarOpen, setSidebarOpen, setPaletteOpen, paletteOpen } = useApp(); const [collapsed, setCollapsed] = useLocalStorage(LS_COLLAPSED, false); const router = useRouter(); const pathname = usePathname(); const isMobile = useIsMobile(); useKeyboardInset(); // Close the drawer on navigation. React.useEffect(() => { setSidebarOpen(false); }, [pathname, setSidebarOpen]); const toggleCollapsed = React.useCallback(() => setCollapsed((c) => !c), [setCollapsed]); // Global shortcuts React.useEffect(() => { const onKey = (e: KeyboardEvent) => { const mod = e.metaKey || e.ctrlKey; const target = e.target as HTMLElement | null; const typing = target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable); if (mod && e.key.toLowerCase() === "k") { e.preventDefault(); setPaletteOpen(!paletteOpen); } else if (mod && e.key.toLowerCase() === "n" && !e.shiftKey) { e.preventDefault(); router.push("/app/chat"); } else if (mod && e.key === "b") { e.preventDefault(); toggleCollapsed(); } else if (!mod && !typing && e.key === "?" ) { e.preventDefault(); setPaletteOpen(true); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [paletteOpen, router, setPaletteOpen, toggleCollapsed]); // Edge swipe (from the left 24px) opens the drawer on phones. const contentRef = React.useRef(null); const openDrawer = React.useCallback(() => setSidebarOpen(true), [setSidebarOpen]); useSwipe(contentRef, { onSwipeRight: openDrawer, edge: 28, disabled: !isMobile || sidebarOpen, ignoreSelector: ".snap-row, [data-no-edge-swipe]" }); // Hide the bottom nav on full-screen surfaces (e.g. share preview) via a data attribute set by pages. const showBottomNav = isMobile && !pathname.startsWith("/app/onboarding"); return (
{/* Desktop sidebar */} {/* Mobile drawer */} setSidebarOpen(false)} />
{collapsed ? ( ) : null} {children}
{showBottomNav ? : null}
); } function MobileDrawer({ open, onClose }: { open: boolean; onClose: () => void }) { const reduced = usePrefersReducedMotion(); const [render, setRender] = React.useState(open); const [closing, setClosing] = React.useState(false); const panelRef = React.useRef(null); React.useEffect(() => { if (open) { // eslint-disable-next-line react-hooks/set-state-in-effect setRender(true); setClosing(false); } else if (render) { setClosing(true); const t = setTimeout(() => { setRender(false); setClosing(false); }, reduced ? 0 : 200); return () => clearTimeout(t); } }, [open, render, reduced]); useSwipe(panelRef, { onSwipeLeft: onClose, disabled: !open }); // Lock body scroll and close on Escape while open. React.useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open, onClose]); if (!render) return null; return (
); }