Add a packaged-client runtime boundary so the shared UI can talk to local, desktop, remote, and VS Code runtimes through the right transport instead of assuming one same-origin web server. Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and runtime URL helpers, while keeping official OpenCode traffic on the SDK path. Support runtime switching, remote host selection, desktop client credentials, and headless connection links for pairing packaged clients with remote OpenChamber servers. Harden the new auth model by moving long-lived client tokens out of browser URLs, introducing short-lived scoped URL tokens for browser-owned transports, restricting URL-token access to explicit readable/realtime routes, and making client-token management session-scoped or self-scoped as appropriate. Update browser-owned assets and preview proxy flows to work with the split runtime model, including authenticated project icons, preview token propagation, CSP-safe preview bridge injection, and preview proxy auth that survives short-lived URL-token expiry. Tighten Electron security boundaries for packaged clients by gating privileged preload state to trusted origins and requiring explicit confirmation before connect deep-links import or switch remote runtimes. Also refresh agent guidance and project skills so future runtime/API, auth, preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new architecture.
658 lines
30 KiB
TypeScript
658 lines
30 KiB
TypeScript
import React, { useRef, useEffect } from 'react';
|
|
import { animate, motion, useMotionValue } 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 { ProjectContextPanel, 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 { useSessionUIStore } from '@/sync/session-ui-store';
|
|
import { useUpdateStore } from '@/stores/useUpdateStore';
|
|
import { useDeviceInfo } from '@/lib/device';
|
|
import { cn } from '@/lib/utils';
|
|
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
|
|
|
import { ChatView } from '@/components/views/ChatView';
|
|
import { DiffView } from '@/components/views/DiffView';
|
|
import { FilesView } from '@/components/views/FilesView';
|
|
import { GitView } from '@/components/views/GitView';
|
|
import { PlanView } from '@/components/views/PlanView';
|
|
|
|
// Heavy views loaded on-demand to reduce initial bundle parse time.
|
|
const TerminalView = lazyWithChunkRecovery(() => import('@/components/views/TerminalView').then(m => ({ default: m.TerminalView })));
|
|
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 })));
|
|
|
|
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 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 sidebarWidth = useUIStore((state) => state.sidebarWidth);
|
|
const rightSidebarWidth = useUIStore((state) => state.rightSidebarWidth);
|
|
const rightSidebarAutoClosedRef = React.useRef(false);
|
|
const bottomTerminalAutoClosedRef = React.useRef(false);
|
|
const mobilePanelsResetRef = React.useRef(false);
|
|
|
|
// Mobile drawer state
|
|
const [mobileLeftDrawerOpen, setMobileLeftDrawerOpen] = React.useState(false);
|
|
const [mobileRightSidebarOpen, setMobileRightSidebarOpen] = React.useState(false);
|
|
const [mobileLeftDrawerVisible, setMobileLeftDrawerVisible] = React.useState(false);
|
|
const [mobileRightDrawerVisible, setMobileRightDrawerVisible] = React.useState(false);
|
|
const setMobileSessionPanelOpen = React.useCallback((open: boolean) => {
|
|
setMobileLeftDrawerOpen(open);
|
|
useUIStore.getState().setSessionSwitcherOpen(open);
|
|
}, []);
|
|
const mobileRightDrawerOpenRef = React.useRef(false);
|
|
const initialDrawerWidthRef = React.useRef(typeof window === 'undefined' ? 0 : window.innerWidth);
|
|
|
|
// Left drawer motion value
|
|
const leftDrawerX = useMotionValue(-initialDrawerWidthRef.current);
|
|
const leftDrawerWidth = useRef(0);
|
|
|
|
// Right drawer motion value
|
|
const rightDrawerX = useMotionValue(initialDrawerWidthRef.current);
|
|
const rightDrawerWidth = useRef(0);
|
|
|
|
// Compute drawer width
|
|
useEffect(() => {
|
|
if (isMobile) {
|
|
leftDrawerWidth.current = window.innerWidth;
|
|
rightDrawerWidth.current = window.innerWidth;
|
|
}
|
|
}, [isMobile]);
|
|
|
|
// Sync left drawer state and motion value
|
|
useEffect(() => {
|
|
if (!isMobile) {
|
|
setMobileLeftDrawerVisible(false);
|
|
return;
|
|
}
|
|
if (mobileLeftDrawerOpen) {
|
|
setMobileLeftDrawerVisible(true);
|
|
}
|
|
animate(leftDrawerX, mobileLeftDrawerOpen ? 0 : -leftDrawerWidth.current, {
|
|
type: 'spring',
|
|
stiffness: 400,
|
|
damping: 35,
|
|
mass: 0.8,
|
|
});
|
|
}, [mobileLeftDrawerOpen, isMobile, leftDrawerX]);
|
|
|
|
// Sync right drawer state and motion value
|
|
useEffect(() => {
|
|
if (!isMobile) {
|
|
setMobileRightDrawerVisible(false);
|
|
return;
|
|
}
|
|
mobileRightDrawerOpenRef.current = mobileRightSidebarOpen;
|
|
if (mobileRightSidebarOpen) {
|
|
setMobileRightDrawerVisible(true);
|
|
}
|
|
animate(rightDrawerX, mobileRightSidebarOpen ? 0 : rightDrawerWidth.current, {
|
|
type: 'spring',
|
|
stiffness: 400,
|
|
damping: 35,
|
|
mass: 0.8,
|
|
});
|
|
}, [isMobile, mobileRightSidebarOpen, rightDrawerX]);
|
|
|
|
useEffect(() => {
|
|
if (!isMobile) return;
|
|
return leftDrawerX.on('change', (value) => {
|
|
const width = leftDrawerWidth.current || initialDrawerWidthRef.current;
|
|
const visible = mobileLeftDrawerOpen || value > -width + 0.5;
|
|
setMobileLeftDrawerVisible((previous) => previous === visible ? previous : visible);
|
|
});
|
|
}, [isMobile, leftDrawerX, mobileLeftDrawerOpen]);
|
|
|
|
useEffect(() => {
|
|
if (!isMobile) return;
|
|
return rightDrawerX.on('change', (value) => {
|
|
const width = rightDrawerWidth.current || initialDrawerWidthRef.current;
|
|
const visible = mobileRightSidebarOpen || value < width - 0.5;
|
|
setMobileRightDrawerVisible((previous) => previous === visible ? previous : visible);
|
|
});
|
|
}, [isMobile, mobileRightSidebarOpen, rightDrawerX]);
|
|
|
|
// Sync session switcher close events to left drawer.
|
|
useEffect(() => {
|
|
if (isMobile && !isSessionSwitcherOpen && mobileLeftDrawerOpen) {
|
|
setMobileSessionPanelOpen(false);
|
|
}
|
|
}, [isSessionSwitcherOpen, isMobile, mobileLeftDrawerOpen, setMobileSessionPanelOpen]);
|
|
|
|
useEffect(() => {
|
|
if (!isMobile) {
|
|
mobilePanelsResetRef.current = false;
|
|
return;
|
|
}
|
|
|
|
if (mobilePanelsResetRef.current) {
|
|
return;
|
|
}
|
|
|
|
mobilePanelsResetRef.current = true;
|
|
setMobileSessionPanelOpen(false);
|
|
setMobileRightSidebarOpen(false);
|
|
if (useUIStore.getState().isRightSidebarOpen) {
|
|
setRightSidebarOpen(false);
|
|
}
|
|
}, [isMobile, setMobileSessionPanelOpen, setRightSidebarOpen]);
|
|
|
|
useEffect(() => {
|
|
if (!isMobile || activeMainTab !== 'chat' || mobileLeftDrawerOpen || mobileRightSidebarOpen || isSettingsDialogOpen) {
|
|
return;
|
|
}
|
|
|
|
let disposed = false;
|
|
let timeoutId: number | undefined;
|
|
|
|
const scheduleDraftOpen = (delayMs: number) => {
|
|
timeoutId = window.setTimeout(() => {
|
|
if (disposed) {
|
|
return;
|
|
}
|
|
|
|
const sessionState = useSessionUIStore.getState();
|
|
const uiState = useUIStore.getState();
|
|
if (uiState.activeMainTab !== 'chat' || uiState.isSettingsDialogOpen || sessionState.currentSessionId || sessionState.newSessionDraft?.open) {
|
|
return;
|
|
}
|
|
|
|
if (sessionState.isLoading) {
|
|
scheduleDraftOpen(250);
|
|
return;
|
|
}
|
|
|
|
sessionState.openNewSessionDraft();
|
|
}, delayMs);
|
|
};
|
|
|
|
scheduleDraftOpen(500);
|
|
|
|
return () => {
|
|
disposed = true;
|
|
if (timeoutId !== undefined) {
|
|
window.clearTimeout(timeoutId);
|
|
}
|
|
};
|
|
}, [activeMainTab, isMobile, isSettingsDialogOpen, mobileLeftDrawerOpen, mobileRightSidebarOpen]);
|
|
|
|
// Ensure mobile drawers are closed when opening full-screen settings
|
|
useEffect(() => {
|
|
if (!isMobile || !isSettingsDialogOpen) {
|
|
return;
|
|
}
|
|
|
|
setMobileSessionPanelOpen(false);
|
|
setMobileRightSidebarOpen(false);
|
|
if (isRightSidebarOpen) {
|
|
setRightSidebarOpen(false);
|
|
}
|
|
}, [isMobile, isSettingsDialogOpen, isRightSidebarOpen, setMobileSessionPanelOpen, setRightSidebarOpen]);
|
|
|
|
// 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 handleToggleMobileRightDrawer = React.useCallback(() => {
|
|
if (mobileLeftDrawerOpen) {
|
|
setMobileSessionPanelOpen(false);
|
|
}
|
|
setMobileRightSidebarOpen(!mobileRightSidebarOpen);
|
|
}, [mobileLeftDrawerOpen, mobileRightSidebarOpen, setMobileSessionPanelOpen]);
|
|
|
|
const secondaryView = React.useMemo(() => {
|
|
switch (activeMainTab) {
|
|
case 'plan':
|
|
return <React.Suspense fallback={null}><PlanView /></React.Suspense>;
|
|
case 'git':
|
|
return <React.Suspense fallback={null}><GitView /></React.Suspense>;
|
|
case 'diff':
|
|
return <React.Suspense fallback={null}><DiffView /></React.Suspense>;
|
|
case 'terminal':
|
|
return <React.Suspense fallback={null}><TerminalView /></React.Suspense>;
|
|
case 'files':
|
|
return <React.Suspense fallback={null}><FilesView /></React.Suspense>;
|
|
case 'context':
|
|
return <React.Suspense fallback={null}><ProjectContextPanel /></React.Suspense>;
|
|
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 (
|
|
<DiffWorkerProvider>
|
|
<div
|
|
data-page-scroll-lock="true"
|
|
className={cn(
|
|
'main-content-safe-area',
|
|
isMobile ? 'flex h-[100dvh] flex-col' : 'flex h-[100dvh]',
|
|
'bg-background'
|
|
)}
|
|
>
|
|
<CommandPalette />
|
|
<HelpDialog />
|
|
<OpenCodeStatusDialog />
|
|
<SessionDialogs />
|
|
|
|
{isMobile ? (
|
|
<DrawerProvider value={{
|
|
leftDrawerOpen: mobileLeftDrawerOpen,
|
|
rightDrawerOpen: mobileRightSidebarOpen,
|
|
toggleLeftDrawer: () => {
|
|
const nextOpen = !mobileLeftDrawerOpen;
|
|
if (mobileRightSidebarOpen) {
|
|
setMobileRightSidebarOpen(false);
|
|
}
|
|
setMobileSessionPanelOpen(nextOpen);
|
|
},
|
|
toggleRightDrawer: handleToggleMobileRightDrawer,
|
|
leftDrawerX,
|
|
rightDrawerX,
|
|
leftDrawerWidth,
|
|
rightDrawerWidth,
|
|
setMobileLeftDrawerOpen: setMobileSessionPanelOpen,
|
|
setRightSidebarOpen: setMobileRightSidebarOpen,
|
|
}}>
|
|
{/* Mobile: header + drawer mode */}
|
|
{!isSettingsDialogOpen && <Header
|
|
onToggleLeftDrawer={() => {
|
|
const nextOpen = !mobileLeftDrawerOpen;
|
|
if (mobileRightSidebarOpen) {
|
|
setMobileRightSidebarOpen(false);
|
|
}
|
|
setMobileSessionPanelOpen(nextOpen);
|
|
}}
|
|
onToggleRightDrawer={() => {
|
|
handleToggleMobileRightDrawer();
|
|
}}
|
|
leftDrawerOpen={mobileLeftDrawerOpen}
|
|
rightDrawerOpen={mobileRightSidebarOpen}
|
|
/>}
|
|
|
|
{/* Main content area (fixed) */}
|
|
<div
|
|
data-page-scroll-lock="true"
|
|
className={cn(
|
|
'flex flex-1 overflow-hidden relative',
|
|
isSettingsDialogOpen && 'hidden'
|
|
)}
|
|
>
|
|
<main className="w-full h-full overflow-hidden bg-background relative" data-page-scroll-lock="true">
|
|
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
|
<ErrorBoundary><ChatView /></ErrorBoundary>
|
|
</div>
|
|
{secondaryView && (
|
|
<div className="absolute inset-0">
|
|
<ErrorBoundary>{secondaryView}</ErrorBoundary>
|
|
</div>
|
|
)}
|
|
{isMultiRunLauncherOpen && (
|
|
<div className="absolute inset-0 z-10 bg-background">
|
|
<ErrorBoundary>
|
|
<MultiRunLauncher
|
|
initialPrompt={multiRunLauncherPrefillPrompt}
|
|
onCreated={() => setMultiRunLauncherOpen(false)}
|
|
onCancel={() => setMultiRunLauncherOpen(false)}
|
|
/>
|
|
</ErrorBoundary>
|
|
</div>
|
|
)}
|
|
{mobileLeftDrawerVisible && (
|
|
<motion.div className="absolute inset-0 z-20 bg-sidebar" data-page-scroll-lock="true" style={{ x: leftDrawerX }} aria-hidden={!mobileLeftDrawerOpen}>
|
|
<ErrorBoundary>
|
|
<SessionSidebar mobileVariant />
|
|
</ErrorBoundary>
|
|
</motion.div>
|
|
)}
|
|
{mobileRightDrawerVisible && (
|
|
<motion.div className="absolute inset-0 z-20 bg-sidebar" data-page-scroll-lock="true" style={{ x: rightDrawerX }} aria-hidden={!mobileRightSidebarOpen}>
|
|
<ErrorBoundary>
|
|
<React.Suspense fallback={null}><GitView /></React.Suspense>
|
|
</ErrorBoundary>
|
|
</motion.div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
|
|
{/* Mobile settings: full screen */}
|
|
{isSettingsDialogOpen && (
|
|
<div
|
|
className="absolute inset-0 z-10 bg-background"
|
|
style={{ paddingTop: 'var(--oc-safe-area-top, 0px)' }}
|
|
>
|
|
<ErrorBoundary>
|
|
<React.Suspense fallback={null}>
|
|
<SettingsView onClose={() => setSettingsDialogOpen(false)} />
|
|
</React.Suspense>
|
|
</ErrorBoundary>
|
|
</div>
|
|
)}
|
|
</DrawerProvider>
|
|
) : (
|
|
<>
|
|
{/* Desktop: full-width Header above [Sidebar | chat-frame | RightSidebar] row */}
|
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
<Header />
|
|
<div className="relative flex flex-1 min-h-0 overflow-hidden bg-sidebar" data-page-scroll-lock="true">
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none absolute top-0 z-0 bg-sidebar transition-[left,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
|
|
style={{
|
|
left: `${isSidebarOpen ? visibleSidebarWidth : 0}px`,
|
|
opacity: isSidebarOpen ? 1 : 0,
|
|
width: '10px',
|
|
height: '10px',
|
|
WebkitMaskImage: 'radial-gradient(circle at 100% 100%, transparent calc(10px - 1px), black 10px)',
|
|
maskImage: 'radial-gradient(circle at 100% 100%, transparent calc(10px - 1px), black 10px)',
|
|
}}
|
|
/>
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none absolute bottom-0 z-0 bg-sidebar transition-[left,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
|
|
style={{
|
|
left: `${isSidebarOpen ? visibleSidebarWidth : 0}px`,
|
|
opacity: isSidebarOpen ? 1 : 0,
|
|
width: '10px',
|
|
height: '10px',
|
|
WebkitMaskImage: 'radial-gradient(circle at 100% 0%, transparent calc(10px - 1px), black 10px)',
|
|
maskImage: 'radial-gradient(circle at 100% 0%, transparent calc(10px - 1px), black 10px)',
|
|
}}
|
|
/>
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none absolute top-0 z-0 bg-sidebar transition-[right,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
|
|
style={{
|
|
right: `${isRightSidebarOpen ? visibleRightSidebarWidth : 0}px`,
|
|
opacity: isRightSidebarOpen ? 1 : 0,
|
|
width: '10px',
|
|
height: '10px',
|
|
WebkitMaskImage: 'radial-gradient(circle at 0 100%, transparent calc(10px - 1px), black 10px)',
|
|
maskImage: 'radial-gradient(circle at 0 100%, transparent calc(10px - 1px), black 10px)',
|
|
}}
|
|
/>
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none absolute bottom-0 z-0 bg-sidebar transition-[right,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
|
|
style={{
|
|
right: `${isRightSidebarOpen ? visibleRightSidebarWidth : 0}px`,
|
|
opacity: isRightSidebarOpen ? 1 : 0,
|
|
width: '10px',
|
|
height: '10px',
|
|
WebkitMaskImage: 'radial-gradient(circle at 0 0, transparent calc(10px - 1px), black 10px)',
|
|
maskImage: 'radial-gradient(circle at 0 0, transparent calc(10px - 1px), black 10px)',
|
|
}}
|
|
/>
|
|
<Sidebar
|
|
isOpen={isSidebarOpen}
|
|
isMobile={isMobile}
|
|
className="border-0"
|
|
>
|
|
<SessionSidebar />
|
|
</Sidebar>
|
|
<div className={cn(
|
|
'relative flex flex-1 min-w-0 flex-col overflow-hidden',
|
|
'bg-background',
|
|
'border border-border/50 rounded-[10px]',
|
|
!isSidebarOpen && 'border-l-transparent',
|
|
!isRightSidebarOpen && 'border-r-transparent'
|
|
)} data-page-scroll-lock="true">
|
|
<div className="flex flex-1 min-h-0 overflow-hidden" data-page-scroll-lock="true">
|
|
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden" data-page-scroll-lock="true">
|
|
<main className="flex-1 overflow-hidden bg-background relative" data-page-scroll-lock="true">
|
|
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
|
<ErrorBoundary><ChatView /></ErrorBoundary>
|
|
</div>
|
|
{secondaryView && (
|
|
<div className="absolute inset-0">
|
|
<ErrorBoundary>{secondaryView}</ErrorBoundary>
|
|
</div>
|
|
)}
|
|
</main>
|
|
<ContextPanel />
|
|
</div>
|
|
</div>
|
|
<BottomTerminalDock isOpen={isBottomTerminalOpen} isMobile={isMobile}>
|
|
{isBottomTerminalOpen ? (
|
|
<ErrorBoundary>
|
|
<React.Suspense fallback={null}>
|
|
<TerminalView />
|
|
</React.Suspense>
|
|
</ErrorBoundary>
|
|
) : null}
|
|
</BottomTerminalDock>
|
|
</div>
|
|
<RightSidebar
|
|
isOpen={isRightSidebarOpen}
|
|
className="border-0"
|
|
>
|
|
<ErrorBoundary><RightSidebarTabs /></ErrorBoundary>
|
|
</RightSidebar>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Desktop settings: windowed dialog with blur */}
|
|
<React.Suspense fallback={null}>
|
|
<SettingsWindow
|
|
open={isSettingsDialogOpen}
|
|
onOpenChange={setSettingsDialogOpen}
|
|
/>
|
|
</React.Suspense>
|
|
<React.Suspense fallback={null}>
|
|
<MultiRunWindow
|
|
open={isMultiRunLauncherOpen}
|
|
onOpenChange={setMultiRunLauncherOpen}
|
|
initialPrompt={multiRunLauncherPrefillPrompt}
|
|
/>
|
|
</React.Suspense>
|
|
</>
|
|
)}
|
|
|
|
</div>
|
|
</DiffWorkerProvider>
|
|
);
|
|
};
|