import { useStore } from '@nanostores/react' import type { CSSProperties, ReactNode, PointerEvent as ReactPointerEvent } from 'react' import { useCallback } from 'react' import { SidebarProvider } from '@/components/ui/sidebar' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' import { $inspectorOpen, $sidebarOpen, $sidebarWidth, setSidebarOpen, setSidebarResizing, setSidebarWidth } from '@/store/layout' import { $previewTarget } from '@/store/preview' import { $connection } from '@/store/session' import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar' import { TitlebarControls } from './titlebar-controls' interface AppShellProps { children: ReactNode inspectorWidth: string rightRailOpen: boolean settingsOpen: boolean sidebar: ReactNode titlebarActions?: ReactNode onOpenSettings: () => void overlays?: ReactNode } export function AppShell({ children, inspectorWidth, rightRailOpen, settingsOpen, sidebar, titlebarActions, onOpenSettings, overlays }: AppShellProps) { const sidebarWidth = useStore($sidebarWidth) const connection = useStore($connection) const sidebarOpen = useStore($sidebarOpen) const inspectorOpen = useStore($inspectorOpen) const previewTarget = useStore($previewTarget) // The shell grid should describe visible app chrome only. Titlebar buttons // and draggable hit-zones are fixed overlays, so keeping an invisible grid // column for a closed sidebar pushes/clips the actual chat surface. const displayedSidebarWidth = sidebarOpen ? sidebarWidth : 0 const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition) const titlebarContentInset = sidebarOpen ? 0 : titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2) const showRightRail = rightRailOpen && (inspectorOpen || Boolean(previewTarget)) // Right rail yields to chat min-width before the chat column starts crushing the composer. const inspectorColumn = showRightRail ? 'min(var(--inspector-width), max(0px, calc(100vw - var(--sidebar-width) - var(--chat-min-width) - 2 * var(--shell-gap))))' : '0px' // Always keep the shell as 3 columns because the sidebar and chat are // always rendered as grid children. Collapsing to a single grid column // makes the hidden sidebar occupy row 1 and pushes chat into row 2, which // looks like a blank/white screen when closing the preview with sidebars // hidden. Centering is handled by setting closed side columns to 0px. const shellGridColumns = 'var(--sidebar-width) minmax(0,1fr) var(--inspector-col)' const startSidebarResize = useCallback( (event: ReactPointerEvent) => { event.preventDefault() setSidebarResizing(true) const startX = event.clientX const startWidth = sidebarWidth const previousCursor = document.body.style.cursor const previousUserSelect = document.body.style.userSelect document.body.style.cursor = 'col-resize' document.body.style.userSelect = 'none' const handleMove = (moveEvent: PointerEvent) => { setSidebarWidth(startWidth + moveEvent.clientX - startX) } const handleUp = () => { setSidebarResizing(false) triggerHaptic('crisp') document.body.style.cursor = previousCursor document.body.style.userSelect = previousUserSelect window.removeEventListener('pointermove', handleMove) window.removeEventListener('pointerup', handleUp) } window.addEventListener('pointermove', handleMove) window.addEventListener('pointerup', handleUp, { once: true }) }, [sidebarWidth] ) return (
{overlays}
) }