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, type TitlebarTool } from './titlebar-controls' interface AppShellProps { children: ReactNode inspectorWidth: string leftTitlebarTools?: readonly TitlebarTool[] previewWidth: string rightRailOpen: boolean settingsOpen: boolean sidebar: ReactNode titlebarTools?: readonly TitlebarTool[] onOpenSettings: () => void overlays?: ReactNode } export function AppShell({ children, inspectorWidth, leftTitlebarTools, previewWidth, rightRailOpen, settingsOpen, sidebar, titlebarTools, 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 showPreviewRail = rightRailOpen && Boolean(previewTarget) const showInspectorRail = rightRailOpen && inspectorOpen const inspectorColumn = showInspectorRail ? 'var(--inspector-width)' : '0px' // Preview yields first because it is the widest rail; keep chat usable before // letting the webview consume horizontal space. const previewColumn = showPreviewRail ? `min(var(--preview-width), max(0px, calc(100vw - var(--sidebar-width) - ${showInspectorRail ? 'var(--inspector-width)' : '0px'} - var(--chat-min-width) - 3 * var(--shell-gap))))` : '0px' const titlebarToolCount = (titlebarTools?.filter(tool => !tool.hidden).length ?? 0) + (rightRailOpen ? 1 : 0) + 2 // Always keep the shell as fixed columns because sidebar/chat/preview/inspector // are always rendered as grid children. Hidden rails collapse to 0px so they // don't float over the chat surface or reorder into a new row. const shellGridColumns = 'var(--sidebar-width) minmax(0,1fr) var(--preview-col) var(--inspector-col)' const hasSideGaps = sidebarOpen || showPreviewRail || showInspectorRail 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}
) }