import React, { useRef, useEffect } from 'react'; import { motion, useMotionValue, animate } from 'motion/react'; import { Header } from './Header'; import { BottomTerminalDock } from './BottomTerminalDock'; import { Sidebar, SIDEBAR_CONTENT_WIDTH } from './Sidebar'; import { RightSidebar, RIGHT_SIDEBAR_CONTENT_WIDTH } from './RightSidebar'; import { RightSidebarTabs } from './RightSidebarTabs'; import { ContextPanel } from './ContextPanel'; import { ErrorBoundary } from '../ui/ErrorBoundary'; import { CommandPalette } from '../ui/CommandPalette'; import { HelpDialog } from '../ui/HelpDialog'; import { OpenCodeStatusDialog } from '../ui/OpenCodeStatusDialog'; import { SessionSidebar } from '@/components/session/SessionSidebar'; import { SessionDialogs } from '@/components/session/SessionDialogs'; import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider'; import { MultiRunLauncher } from '@/components/multirun'; import { DrawerProvider } from '@/contexts/DrawerContext'; import { useUIStore } from '@/stores/useUIStore'; import { useUpdateStore } from '@/stores/useUpdateStore'; import { useDeviceInfo } from '@/lib/device'; import { useVisualViewport } from '@/hooks/useVisualViewport'; import { useI18n } from '@/lib/i18n'; import { cn } from '@/lib/utils'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import { ChatView } from '@/components/views/ChatView'; // Heavy views loaded on-demand to reduce initial bundle parse time. const PlanView = lazyWithChunkRecovery(() => import('@/components/views/PlanView').then(m => ({ default: m.PlanView }))); const GitView = lazyWithChunkRecovery(() => import('@/components/views/GitView').then(m => ({ default: m.GitView }))); const DiffView = lazyWithChunkRecovery(() => import('@/components/views/DiffView').then(m => ({ default: m.DiffView }))); const TerminalView = lazyWithChunkRecovery(() => import('@/components/views/TerminalView').then(m => ({ default: m.TerminalView }))); const FilesView = lazyWithChunkRecovery(() => import('@/components/views/FilesView').then(m => ({ default: m.FilesView }))); const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView }))); const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow }))); const MultiRunWindow = lazyWithChunkRecovery(() => import('@/components/views/MultiRunWindow').then(m => ({ default: m.MultiRunWindow }))); // Mobile drawer width as screen percentage const MOBILE_DRAWER_WIDTH_PERCENT = 85; const DESKTOP_SIDEBAR_MIN_WIDTH = 280; const DESKTOP_SIDEBAR_MAX_WIDTH = 500; const DESKTOP_RIGHT_SIDEBAR_MIN_WIDTH = 360; const DESKTOP_RIGHT_SIDEBAR_MAX_WIDTH = 860; export const MainLayout: React.FC = () => { const { t } = useI18n(); const RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH = 1140; const RIGHT_SIDEBAR_AUTO_OPEN_WIDTH = 1220; const BOTTOM_TERMINAL_AUTO_CLOSE_HEIGHT = 640; const BOTTOM_TERMINAL_AUTO_OPEN_HEIGHT = 700; const isSidebarOpen = useUIStore((state) => state.isSidebarOpen); const isRightSidebarOpen = useUIStore((state) => state.isRightSidebarOpen); const isBottomTerminalOpen = useUIStore((state) => state.isBottomTerminalOpen); const setRightSidebarOpen = useUIStore((state) => state.setRightSidebarOpen); const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen); const activeMainTab = useUIStore((state) => state.activeMainTab); const setIsMobile = useUIStore((state) => state.setIsMobile); const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen); const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const isMultiRunLauncherOpen = useUIStore((state) => state.isMultiRunLauncherOpen); const setMultiRunLauncherOpen = useUIStore((state) => state.setMultiRunLauncherOpen); const multiRunLauncherPrefillPrompt = useUIStore((state) => state.multiRunLauncherPrefillPrompt); const { isMobile, isTablet } = useDeviceInfo(); const visualViewport = useVisualViewport(); const sidebarWidth = useUIStore((state) => state.sidebarWidth); const rightSidebarWidth = useUIStore((state) => state.rightSidebarWidth); const rightSidebarAutoClosedRef = React.useRef(false); const bottomTerminalAutoClosedRef = React.useRef(false); // Mobile drawer state const [mobileLeftDrawerOpen, setMobileLeftDrawerOpen] = React.useState(false); const mobileRightDrawerOpenRef = React.useRef(false); // Left drawer motion value const leftDrawerX = useMotionValue(0); const leftDrawerWidth = useRef(0); // Right drawer motion value const rightDrawerX = useMotionValue(0); const rightDrawerWidth = useRef(0); // Compute drawer width useEffect(() => { if (isMobile) { leftDrawerWidth.current = window.innerWidth * (MOBILE_DRAWER_WIDTH_PERCENT / 100); rightDrawerWidth.current = window.innerWidth * (MOBILE_DRAWER_WIDTH_PERCENT / 100); } }, [isMobile]); // Sync left drawer state and motion value useEffect(() => { if (!isMobile) return; const targetX = mobileLeftDrawerOpen ? 0 : -leftDrawerWidth.current; animate(leftDrawerX, targetX, { type: "spring", stiffness: 400, damping: 35, mass: 0.8 }); }, [mobileLeftDrawerOpen, isMobile, leftDrawerX]); // Sync right drawer state and motion value useEffect(() => { if (!isMobile) return; mobileRightDrawerOpenRef.current = isRightSidebarOpen; const targetX = isRightSidebarOpen ? 0 : rightDrawerWidth.current; animate(rightDrawerX, targetX, { type: "spring", stiffness: 400, damping: 35, mass: 0.8 }); }, [isMobile, isRightSidebarOpen, rightDrawerX]); // Sync session switcher state to left drawer (one-way) useEffect(() => { if (isMobile) { setMobileLeftDrawerOpen(isSessionSwitcherOpen); } }, [isSessionSwitcherOpen, isMobile]); // Ensure mobile drawers are closed when opening full-screen settings useEffect(() => { if (!isMobile || !isSettingsDialogOpen) { return; } setMobileLeftDrawerOpen(false); if (isSessionSwitcherOpen) { useUIStore.getState().setSessionSwitcherOpen(false); } if (isRightSidebarOpen) { setRightSidebarOpen(false); } }, [isMobile, isSettingsDialogOpen, isSessionSwitcherOpen, isRightSidebarOpen, setRightSidebarOpen]); // Sync right drawer and git sidebar state useEffect(() => { if (isMobile) { mobileRightDrawerOpenRef.current = isRightSidebarOpen; } }, [isRightSidebarOpen, isMobile]); // Trigger initial update check shortly after mount, then repeat using server-suggested cadence. const checkForUpdates = useUpdateStore((state) => state.checkForUpdates); React.useEffect(() => { const initialDelayMs = 3000; const defaultIntervalMs = 60 * 60 * 1000; const minIntervalMs = 5 * 60 * 1000; const maxIntervalMs = 24 * 60 * 60 * 1000; let disposed = false; let timer: number | null = null; const clampIntervalMs = (seconds: number): number => { const ms = Math.round(seconds * 1000); return Math.max(minIntervalMs, Math.min(maxIntervalMs, ms)); }; const scheduleNext = (delayMs: number) => { if (disposed) return; timer = window.setTimeout(async () => { const suggestedSec = await checkForUpdates(); const nextDelay = typeof suggestedSec === 'number' && Number.isFinite(suggestedSec) ? clampIntervalMs(suggestedSec) : defaultIntervalMs; scheduleNext(nextDelay); }, delayMs); }; scheduleNext(initialDelayMs); return () => { disposed = true; if (timer !== null) { window.clearTimeout(timer); } }; }, [checkForUpdates]); React.useEffect(() => { const previous = useUIStore.getState().isMobile; if (previous !== isMobile) { setIsMobile(isMobile); } }, [isMobile, setIsMobile]); React.useEffect(() => { if (typeof window === 'undefined') { return; } let timeoutId: number | undefined; const handleResize = () => { if (timeoutId !== undefined) { window.clearTimeout(timeoutId); } timeoutId = window.setTimeout(() => { useUIStore.getState().updateProportionalSidebarWidths(); }, 150); }; window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); if (timeoutId !== undefined) { window.clearTimeout(timeoutId); } }; }, []); React.useEffect(() => { if (typeof window === 'undefined') { return; } let timeoutId: number | undefined; const handleResponsivePanels = () => { const state = useUIStore.getState(); const width = window.innerWidth; const height = window.innerHeight; // Touch devices frequently resize when the on-screen keyboard opens. // Treat panel auto-collapse/restore as desktop-only so keyboard // viewport changes do not churn drawer or terminal layout state. if (!isMobile && !isTablet) { const shouldCloseRightSidebar = width < RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH; const canAutoOpenRightSidebar = width >= RIGHT_SIDEBAR_AUTO_OPEN_WIDTH; if (shouldCloseRightSidebar) { if (state.isRightSidebarOpen) { setRightSidebarOpen(false); rightSidebarAutoClosedRef.current = true; } } else if (canAutoOpenRightSidebar && rightSidebarAutoClosedRef.current) { setRightSidebarOpen(true); rightSidebarAutoClosedRef.current = false; } const shouldCloseBottomTerminal = height < BOTTOM_TERMINAL_AUTO_CLOSE_HEIGHT; const canAutoOpenBottomTerminal = height >= BOTTOM_TERMINAL_AUTO_OPEN_HEIGHT; if (shouldCloseBottomTerminal) { if (state.isBottomTerminalOpen) { setBottomTerminalOpen(false); bottomTerminalAutoClosedRef.current = true; } } else if (canAutoOpenBottomTerminal && bottomTerminalAutoClosedRef.current) { setBottomTerminalOpen(true); bottomTerminalAutoClosedRef.current = false; } } }; const handleResize = () => { if (timeoutId !== undefined) { window.clearTimeout(timeoutId); } timeoutId = window.setTimeout(() => { handleResponsivePanels(); }, 100); }; handleResponsivePanels(); window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); if (timeoutId !== undefined) { window.clearTimeout(timeoutId); } }; }, [isMobile, isTablet, setBottomTerminalOpen, setRightSidebarOpen]); React.useEffect(() => { if (typeof window === 'undefined') { return; } const unsubscribe = useUIStore.subscribe((state, prevState) => { const width = window.innerWidth; const height = window.innerHeight; const rightCanAutoOpen = width >= RIGHT_SIDEBAR_AUTO_OPEN_WIDTH; const bottomCanAutoOpen = height >= BOTTOM_TERMINAL_AUTO_OPEN_HEIGHT; if (state.isRightSidebarOpen !== prevState.isRightSidebarOpen && rightCanAutoOpen) { rightSidebarAutoClosedRef.current = false; } if (state.isBottomTerminalOpen !== prevState.isBottomTerminalOpen && bottomCanAutoOpen) { bottomTerminalAutoClosedRef.current = false; } }); return () => { unsubscribe(); }; }, [isMobile, isTablet, setBottomTerminalOpen, setRightSidebarOpen]); const secondaryView = React.useMemo(() => { switch (activeMainTab) { case 'plan': return ; case 'git': return ; case 'diff': return ; case 'terminal': return ; case 'files': return ; default: return null; } }, [activeMainTab]); const isChatActive = activeMainTab === 'chat'; const visibleSidebarWidth = React.useMemo(() => { const rawWidth = sidebarWidth || SIDEBAR_CONTENT_WIDTH; return Math.min(DESKTOP_SIDEBAR_MAX_WIDTH, Math.max(DESKTOP_SIDEBAR_MIN_WIDTH, rawWidth)); }, [sidebarWidth]); const visibleRightSidebarWidth = React.useMemo(() => { const rawWidth = rightSidebarWidth || RIGHT_SIDEBAR_CONTENT_WIDTH; return Math.min(DESKTOP_RIGHT_SIDEBAR_MAX_WIDTH, Math.max(DESKTOP_RIGHT_SIDEBAR_MIN_WIDTH, rawWidth)); }, [rightSidebarWidth]); return (
0 ? { height: visualViewport.height } : undefined} > {isMobile ? ( { if (isRightSidebarOpen) { setRightSidebarOpen(false); } setMobileLeftDrawerOpen(!mobileLeftDrawerOpen); }, toggleRightDrawer: () => { if (mobileLeftDrawerOpen) { setMobileLeftDrawerOpen(false); } setRightSidebarOpen(!isRightSidebarOpen); }, leftDrawerX, rightDrawerX, leftDrawerWidth, rightDrawerWidth, setMobileLeftDrawerOpen, setRightSidebarOpen, }}> {/* Mobile: header + drawer mode */} {!isSettingsDialogOpen &&
{ if (isRightSidebarOpen) { setRightSidebarOpen(false); } setMobileLeftDrawerOpen(!mobileLeftDrawerOpen); }} onToggleRightDrawer={() => { if (mobileLeftDrawerOpen) { setMobileLeftDrawerOpen(false); } setRightSidebarOpen(!isRightSidebarOpen); }} leftDrawerOpen={mobileLeftDrawerOpen} rightDrawerOpen={isRightSidebarOpen} />} {/* Backdrop */} { setMobileLeftDrawerOpen(false); setRightSidebarOpen(false); }} aria-label={t('mainLayout.mobile.closeDrawerAria')} /> {/* Left drawer (Session) */} { const drawerWidthPx = leftDrawerWidth.current || window.innerWidth * 0.85; const threshold = drawerWidthPx * 0.3; const velocityThreshold = 500; const currentX = leftDrawerX.get(); const shouldClose = info.offset.x < -threshold || info.velocity.x < -velocityThreshold; const shouldOpen = info.offset.x > threshold || info.velocity.x > velocityThreshold; if (shouldClose) { leftDrawerX.set(-drawerWidthPx); setMobileLeftDrawerOpen(false); } else if (shouldOpen) { leftDrawerX.set(0); setMobileLeftDrawerOpen(true); } else { if (currentX > -drawerWidthPx / 2) { leftDrawerX.set(0); } else { leftDrawerX.set(-drawerWidthPx); } } }} className={cn( 'fixed left-0 top-[var(--oc-header-height,56px)] z-50 h-[calc(100%-var(--oc-header-height,56px))] bg-background', 'cursor-grab active:cursor-grabbing' )} aria-hidden={!mobileLeftDrawerOpen} >
{/* Right drawer (Git) */} { const drawerWidthPx = rightDrawerWidth.current || window.innerWidth * 0.85; const threshold = drawerWidthPx * 0.3; const velocityThreshold = 500; const currentX = rightDrawerX.get(); const shouldClose = info.offset.x > threshold || info.velocity.x > velocityThreshold; const shouldOpen = info.offset.x < -threshold || info.velocity.x < -velocityThreshold; if (shouldClose) { rightDrawerX.set(drawerWidthPx); setRightSidebarOpen(false); } else if (shouldOpen) { rightDrawerX.set(0); setRightSidebarOpen(true); } else { if (currentX < drawerWidthPx / 2) { rightDrawerX.set(0); } else { rightDrawerX.set(drawerWidthPx); } } }} className={cn( 'fixed right-0 top-[var(--oc-header-height,56px)] z-50 h-[calc(100%-var(--oc-header-height,56px))] bg-background', 'cursor-grab active:cursor-grabbing' )} aria-hidden={!isRightSidebarOpen} >
{/* Main content area (fixed) */}
{secondaryView && (
{secondaryView}
)} {isMultiRunLauncherOpen && (
setMultiRunLauncherOpen(false)} onCancel={() => setMultiRunLauncherOpen(false)} />
)}
{/* Mobile settings: full screen */} {isSettingsDialogOpen && (
setSettingsDialogOpen(false)} />
)} ) : ( <> {/* Desktop: full-width Header above [Sidebar | chat-frame | RightSidebar] row */}
{secondaryView && (
{secondaryView}
)}
{isBottomTerminalOpen ? ( ) : null}
{/* Desktop settings: windowed dialog with blur */} )}
); };