Files
openchamber/packages/ui/src/components/layout/MainLayout.tsx
T

420 lines
18 KiB
TypeScript
Raw Normal View History

2025-12-07 19:32:53 +02:00
import React from 'react';
2026-01-22 14:12:19 +02:00
import { Header } from './Header';
2025-12-07 19:32:53 +02:00
import { Sidebar } from './Sidebar';
import { ErrorBoundary } from '../ui/ErrorBoundary';
import { CommandPalette } from '../ui/CommandPalette';
import { HelpDialog } from '../ui/HelpDialog';
import { OpenCodeStatusDialog } from '../ui/OpenCodeStatusDialog';
2025-12-07 19:32:53 +02:00
import { SessionSidebar } from '@/components/session/SessionSidebar';
import { SessionDialogs } from '@/components/session/SessionDialogs';
import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider';
import { MultiRunLauncher } from '@/components/multirun';
2025-12-07 19:32:53 +02:00
import { useUIStore } from '@/stores/useUIStore';
import { useUpdateStore } from '@/stores/useUpdateStore';
2025-12-07 19:32:53 +02:00
import { useDeviceInfo } from '@/lib/device';
import { useEdgeSwipe } from '@/hooks/useEdgeSwipe';
import { cn } from '@/lib/utils';
2026-02-01 20:55:39 +02:00
import { ChatView, PlanView, GitView, DiffView, TerminalView, FilesView, SettingsView, SettingsWindow } from '@/components/views';
2025-12-07 19:32:53 +02:00
export const MainLayout: React.FC = () => {
const {
isSidebarOpen,
activeMainTab,
setIsMobile,
isSessionSwitcherOpen,
isSettingsDialogOpen,
setSettingsDialogOpen,
isMultiRunLauncherOpen,
setMultiRunLauncherOpen,
multiRunLauncherPrefillPrompt,
2025-12-07 19:32:53 +02:00
} = useUIStore();
2025-12-07 19:32:53 +02:00
const { isMobile } = useDeviceInfo();
useEdgeSwipe({ enabled: true });
// Trigger update check 3 seconds after mount (for both mobile and desktop)
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
React.useEffect(() => {
const timer = setTimeout(() => {
checkForUpdates();
}, 3000);
return () => clearTimeout(timer);
}, [checkForUpdates]);
2025-12-07 19:32:53 +02:00
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' || typeof document === 'undefined') {
return;
}
const root = document.documentElement;
let stickyKeyboardInset = 0;
let ignoreOpenUntilZero = false;
let previousHeight = 0;
let keyboardAvoidTarget: HTMLElement | null = null;
const setKeyboardOpen = useUIStore.getState().setKeyboardOpen;
const clearKeyboardAvoidTarget = () => {
if (!keyboardAvoidTarget) {
return;
}
keyboardAvoidTarget.style.setProperty('--oc-keyboard-avoid-offset', '0px');
keyboardAvoidTarget.removeAttribute('data-keyboard-avoid-active');
keyboardAvoidTarget = null;
};
const resolveKeyboardAvoidTarget = (active: HTMLElement | null) => {
if (!active) {
return null;
}
const markedTarget = active.closest('[data-keyboard-avoid]') as HTMLElement | null;
if (markedTarget) {
return markedTarget;
}
if (active.classList.contains('overlay-scrollbar-container')) {
const parent = active.parentElement;
if (parent instanceof HTMLElement) {
return parent;
}
}
return active;
};
const forceKeyboardClosed = () => {
stickyKeyboardInset = 0;
ignoreOpenUntilZero = true;
root.style.setProperty('--oc-keyboard-inset', '0px');
setKeyboardOpen(false);
};
const updateVisualViewport = () => {
const viewport = window.visualViewport;
const height = viewport ? Math.round(viewport.height) : window.innerHeight;
const offsetTop = viewport ? Math.max(0, Math.round(viewport.offsetTop)) : 0;
root.style.setProperty('--oc-visual-viewport-offset-top', `${offsetTop}px`);
const active = document.activeElement as HTMLElement | null;
const tagName = active?.tagName;
const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT';
const isTextTarget = isInput || Boolean(active?.isContentEditable);
const layoutHeight = Math.round(root.clientHeight || window.innerHeight);
const viewportSum = height + offsetTop;
const rawInset = Math.max(0, layoutHeight - viewportSum);
// Keyboard heuristic:
// - when an input is focused, smaller deltas can still be keyboard
// - when not focused, treat only big deltas as keyboard (ignore toolbars)
const openThreshold = isTextTarget ? 120 : 180;
const measuredInset = rawInset >= openThreshold ? rawInset : 0;
// Make the UI stable: treat keyboard inset as a step function.
// - When opening: take the first big inset and hold it.
// - When closing starts: immediately drop to 0 (even if keyboard animation continues).
// Closing start signals:
// - focus lost (handled via focusout)
// - visual viewport height starts increasing while inset is non-zero
if (ignoreOpenUntilZero) {
if (measuredInset === 0) {
ignoreOpenUntilZero = false;
}
stickyKeyboardInset = 0;
} else if (stickyKeyboardInset === 0) {
if (measuredInset > 0 && isTextTarget) {
stickyKeyboardInset = measuredInset;
}
} else {
// Only detect closing-by-height when focus is NOT on text input
// (prevents false positives during Android keyboard animation)
const closingByHeight = !isTextTarget && height > previousHeight + 6;
if (measuredInset === 0) {
stickyKeyboardInset = 0;
setKeyboardOpen(false);
} else if (closingByHeight) {
forceKeyboardClosed();
} else if (measuredInset > 0 && isTextTarget) {
// When focus is on text input, track actual inset (allows settling
// to correct value after Android animation fluctuations)
stickyKeyboardInset = measuredInset;
setKeyboardOpen(true);
} else if (measuredInset > stickyKeyboardInset) {
stickyKeyboardInset = measuredInset;
setKeyboardOpen(true);
}
}
root.style.setProperty('--oc-keyboard-inset', `${stickyKeyboardInset}px`);
previousHeight = height;
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const keyboardHomeIndicator = isIOS && stickyKeyboardInset > 0 ? 34 : 0;
root.style.setProperty('--oc-keyboard-home-indicator', `${keyboardHomeIndicator}px`);
const avoidTarget = isTextTarget ? resolveKeyboardAvoidTarget(active) : null;
if (!isMobile || !avoidTarget || !active) {
clearKeyboardAvoidTarget();
} else {
if (avoidTarget !== keyboardAvoidTarget) {
clearKeyboardAvoidTarget();
keyboardAvoidTarget = avoidTarget;
}
const viewportBottom = offsetTop + height;
const rect = active.getBoundingClientRect();
const overlap = rect.bottom - viewportBottom;
const clearance = 8;
const keyboardInset = Math.max(stickyKeyboardInset, measuredInset);
const avoidOffset = overlap > clearance && keyboardInset > 0
? Math.min(overlap, keyboardInset)
: 0;
const target = keyboardAvoidTarget;
if (target) {
target.style.setProperty('--oc-keyboard-avoid-offset', `${avoidOffset}px`);
target.setAttribute('data-keyboard-avoid-active', 'true');
}
}
// Only force-scroll lock while an input is focused.
if (isMobile && isTextTarget) {
const scroller = document.scrollingElement;
if (scroller && scroller.scrollTop !== 0) {
scroller.scrollTop = 0;
}
if (window.scrollY !== 0) {
window.scrollTo(0, 0);
}
}
};
updateVisualViewport();
const viewport = window.visualViewport;
viewport?.addEventListener('resize', updateVisualViewport);
viewport?.addEventListener('scroll', updateVisualViewport);
window.addEventListener('resize', updateVisualViewport);
window.addEventListener('orientationchange', updateVisualViewport);
// Reset ignoreOpenUntilZero when focus moves to a text input.
// This allows keyboard detection to work when user taps input quickly
// while keyboard is still closing (common on Android).
const handleFocusIn = (event: FocusEvent) => {
const target = event.target as HTMLElement | null;
if (!target) {
return;
}
const tagName = target.tagName;
const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT';
if (isInput || target.isContentEditable) {
ignoreOpenUntilZero = false;
}
updateVisualViewport();
};
document.addEventListener('focusin', handleFocusIn, true);
const handleFocusOut = (event: FocusEvent) => {
const target = event.target as HTMLElement | null;
if (!target) {
return;
}
const tagName = target.tagName;
const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT';
if (isInput || target.isContentEditable) {
// Check if focus is moving to another input - if so, don't close keyboard
const related = event.relatedTarget as HTMLElement | null;
const relatedTag = related?.tagName;
const relatedIsInput = relatedTag === 'INPUT' || relatedTag === 'TEXTAREA' || relatedTag === 'SELECT' || related?.isContentEditable;
if (relatedIsInput) {
return;
}
forceKeyboardClosed();
}
};
document.addEventListener('focusout', handleFocusOut, true);
return () => {
viewport?.removeEventListener('resize', updateVisualViewport);
viewport?.removeEventListener('scroll', updateVisualViewport);
window.removeEventListener('resize', updateVisualViewport);
window.removeEventListener('orientationchange', updateVisualViewport);
document.removeEventListener('focusin', handleFocusIn, true);
document.removeEventListener('focusout', handleFocusOut, true);
clearKeyboardAvoidTarget();
};
}, [isMobile]);
2025-12-07 19:32:53 +02:00
const secondaryView = React.useMemo(() => {
switch (activeMainTab) {
case 'plan':
return <PlanView />;
2025-12-07 19:32:53 +02:00
case 'git':
return <GitView />;
case 'diff':
return <DiffView />;
case 'terminal':
return <TerminalView />;
case 'files':
return <FilesView />;
2025-12-07 19:32:53 +02:00
default:
return null;
}
}, [activeMainTab]);
const isChatActive = activeMainTab === 'chat';
return (
<DiffWorkerProvider>
<div
className={cn(
'main-content-safe-area h-[100dvh]',
isMobile ? 'flex flex-col' : 'flex',
'bg-background'
)}
>
<CommandPalette />
<HelpDialog />
<OpenCodeStatusDialog />
<SessionDialogs />
{isMobile ? (
2025-12-07 19:32:53 +02:00
<>
2026-01-22 14:12:19 +02:00
{/* Mobile: Header + content with drill-down pattern */}
{!(isSettingsDialogOpen || isMultiRunLauncherOpen) && <Header />}
<div
className={cn(
2026-01-22 14:12:19 +02:00
'flex flex-1 overflow-hidden',
(isSettingsDialogOpen || isMultiRunLauncherOpen) && 'hidden'
)}
style={{ paddingTop: 'var(--oc-header-height, 56px)' }}
>
2026-01-22 14:12:19 +02:00
{/* Mobile drill-down: show sessions sidebar OR main content */}
{isSessionSwitcherOpen ? (
<div className="flex-1 overflow-hidden bg-sidebar">
<ErrorBoundary><SessionSidebar mobileVariant /></ErrorBoundary>
2025-12-07 19:32:53 +02:00
</div>
2026-01-22 14:12:19 +02:00
) : (
<main className="flex-1 overflow-hidden bg-background relative">
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
<ErrorBoundary><ChatView /></ErrorBoundary>
2025-12-07 19:32:53 +02:00
</div>
2026-01-22 14:12:19 +02:00
{secondaryView && (
<div className="absolute inset-0">
<ErrorBoundary>{secondaryView}</ErrorBoundary>
</div>
)}
</main>
)}
2025-12-07 19:32:53 +02:00
</div>
{/* Mobile multi-run launcher: full screen */}
{isMultiRunLauncherOpen && (
<div className="absolute inset-0 z-10 bg-background header-safe-area">
<ErrorBoundary>
<MultiRunLauncher
initialPrompt={multiRunLauncherPrefillPrompt}
onCreated={() => setMultiRunLauncherOpen(false)}
onCancel={() => setMultiRunLauncherOpen(false)}
/>
</ErrorBoundary>
</div>
)}
{/* Mobile settings: full screen */}
{isSettingsDialogOpen && (
<div className="absolute inset-0 z-10 bg-background header-safe-area">
<ErrorBoundary><SettingsView onClose={() => setSettingsDialogOpen(false)} /></ErrorBoundary>
</div>
)}
2025-12-07 19:32:53 +02:00
</>
) : (
<>
2026-01-22 14:12:19 +02:00
{/* Desktop: Header always on top, then Sidebar + Content below */}
<div className="flex flex-1 flex-col overflow-hidden relative">
2026-01-22 14:12:19 +02:00
{/* Normal view: Header above Sidebar + content (like SettingsView) */}
2026-02-01 20:55:39 +02:00
<div className={cn('absolute inset-0 flex flex-col', isMultiRunLauncherOpen && 'invisible')}>
<Header />
2026-01-22 14:12:19 +02:00
<div className="flex flex-1 overflow-hidden">
<Sidebar isOpen={isSidebarOpen} isMobile={isMobile}>
<SessionSidebar />
</Sidebar>
<main className="flex-1 overflow-hidden bg-background relative">
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
<ErrorBoundary><ChatView /></ErrorBoundary>
2025-12-07 19:32:53 +02:00
</div>
{secondaryView && (
<div className="absolute inset-0">
<ErrorBoundary>{secondaryView}</ErrorBoundary>
</div>
)}
</main>
</div>
2025-12-07 19:32:53 +02:00
</div>
{/* Multi-Run Launcher: replaces tabs content only */}
{isMultiRunLauncherOpen && (
<div className={cn('absolute inset-0 z-10 bg-background')}>
<ErrorBoundary>
<MultiRunLauncher
initialPrompt={multiRunLauncherPrefillPrompt}
onCreated={() => setMultiRunLauncherOpen(false)}
onCancel={() => setMultiRunLauncherOpen(false)}
/>
</ErrorBoundary>
</div>
)}
2025-12-07 19:32:53 +02:00
</div>
2026-02-01 20:55:39 +02:00
{/* Desktop settings: windowed dialog with blur */}
<SettingsWindow
open={isSettingsDialogOpen}
onOpenChange={setSettingsDialogOpen}
/>
2025-12-07 19:32:53 +02:00
</>
)}
</div>
</DiffWorkerProvider>
2025-12-07 19:32:53 +02:00
);
};