TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { usePathname, useRouter } from "next/navigation";4import { PanelLeft } from "lucide-react";5import { useApp } from "./store";6import { Sidebar } from "./sidebar";7import { CommandPalette } from "./command-palette";8import { BottomNav } from "./bottom-nav";9import { Button } from "@/components/ui/button";10import { useIsMobile, useKeyboardInset, useLocalStorage, usePrefersReducedMotion, useSwipe } from "@/lib/client/hooks";11import { cn } from "@/lib/utils";1213const LS_COLLAPSED = "polyllm:sidebar-collapsed";1415/**16 * Application shell.17 *18 * Desktop (≥ md): persistent left sidebar (collapsible, ⌘B) + content.19 * Phone: full-viewport content (`.h-app` tracks the keyboard), a slide-over conversation drawer20 * (edge-swipe to open, swipe left / tap scrim to close) and a persistent bottom navigation.21 */22export function AppShell({ children }: { children: React.ReactNode }) {23 const { sidebarOpen, setSidebarOpen, setPaletteOpen, paletteOpen } = useApp();24 const [collapsed, setCollapsed] = useLocalStorage<boolean>(LS_COLLAPSED, false);25 const router = useRouter();26 const pathname = usePathname();27 const isMobile = useIsMobile();28 useKeyboardInset();2930 // Close the drawer on navigation.31 React.useEffect(() => {32 setSidebarOpen(false);33 }, [pathname, setSidebarOpen]);3435 const toggleCollapsed = React.useCallback(() => setCollapsed((c) => !c), [setCollapsed]);3637 // Global shortcuts38 React.useEffect(() => {39 const onKey = (e: KeyboardEvent) => {40 const mod = e.metaKey || e.ctrlKey;41 const target = e.target as HTMLElement | null;42 const typing = target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable);43 if (mod && e.key.toLowerCase() === "k") {44 e.preventDefault();45 setPaletteOpen(!paletteOpen);46 } else if (mod && e.key.toLowerCase() === "n" && !e.shiftKey) {47 e.preventDefault();48 router.push("/app/chat");49 } else if (mod && e.key === "b") {50 e.preventDefault();51 toggleCollapsed();52 } else if (!mod && !typing && e.key === "?" ) {53 e.preventDefault();54 setPaletteOpen(true);55 }56 };57 window.addEventListener("keydown", onKey);58 return () => window.removeEventListener("keydown", onKey);59 }, [paletteOpen, router, setPaletteOpen, toggleCollapsed]);6061 // Edge swipe (from the left 24px) opens the drawer on phones.62 const contentRef = React.useRef<HTMLDivElement>(null);63 const openDrawer = React.useCallback(() => setSidebarOpen(true), [setSidebarOpen]);64 useSwipe(contentRef, { onSwipeRight: openDrawer, edge: 28, disabled: !isMobile || sidebarOpen, ignoreSelector: ".snap-row, [data-no-edge-swipe]" });6566 // Hide the bottom nav on full-screen surfaces (e.g. share preview) via a data attribute set by pages.67 const showBottomNav = isMobile && !pathname.startsWith("/app/onboarding");6869 return (70 <div className="flex h-app w-full overflow-hidden bg-bg">71 {/* Desktop sidebar */}72 <aside className={cn("hidden md:flex h-full shrink-0 flex-col border-r border-border bg-bg-subtle transition-[width] duration-200 ease-out", collapsed ? "w-0 overflow-hidden border-r-0" : "w-[276px]")}>73 <Sidebar onCollapse={toggleCollapsed} />74 </aside>7576 {/* Mobile drawer */}77 <MobileDrawer open={sidebarOpen && isMobile} onClose={() => setSidebarOpen(false)} />7879 <div ref={contentRef} className={cn("relative flex min-w-0 flex-1 flex-col", showBottomNav && "pb-[calc(var(--bottom-nav-h)+var(--sab))] [html[data-keyboard]_&]:pb-0")}>80 {collapsed ? (81 <Button variant="ghost" size="icon-sm" className="absolute left-2 top-2 z-20 hidden md:inline-flex" onClick={toggleCollapsed} aria-label="Show sidebar">82 <PanelLeft />83 </Button>84 ) : null}85 {children}86 </div>8788 {showBottomNav ? <BottomNav /> : null}89 <CommandPalette />90 </div>91 );92}9394function MobileDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {95 const reduced = usePrefersReducedMotion();96 const [render, setRender] = React.useState(open);97 const [closing, setClosing] = React.useState(false);98 const panelRef = React.useRef<HTMLDivElement>(null);99100 React.useEffect(() => {101 if (open) {102 // eslint-disable-next-line react-hooks/set-state-in-effect103 setRender(true);104 setClosing(false);105 } else if (render) {106 setClosing(true);107 const t = setTimeout(() => {108 setRender(false);109 setClosing(false);110 }, reduced ? 0 : 200);111 return () => clearTimeout(t);112 }113 }, [open, render, reduced]);114115 useSwipe(panelRef, { onSwipeLeft: onClose, disabled: !open });116117 // Lock body scroll and close on Escape while open.118 React.useEffect(() => {119 if (!open) return;120 const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();121 window.addEventListener("keydown", onKey);122 return () => window.removeEventListener("keydown", onKey);123 }, [open, onClose]);124125 if (!render) return null;126 return (127 <div className="fixed inset-0 z-40 md:hidden" role="dialog" aria-modal="true" aria-label="Conversations">128 <div className={cn("absolute inset-0 bg-black/45 backdrop-blur-[2px]", closing ? "animate-fade-out" : "animate-fade-in")} onClick={onClose} />129 <div ref={panelRef} className={cn("absolute inset-y-0 left-0 flex w-[min(86vw,340px)] flex-col bg-bg-subtle shadow-lg pt-[var(--sat)] pb-[var(--sab)]", closing ? "animate-drawer-out" : "animate-drawer-in")}>130 <Sidebar onCollapse={onClose} mobile />131 </div>132 </div>133 );134}135