Fix mobile fullscreen panels and header active state (#1366)

* Render mobile side panels fullscreen below the header

* Fix bot comments

---------

Co-authored-by: Konstantin Zolin <zolin_ka@vk.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
kostazol
2026-05-24 13:55:01 +03:00
committed by GitHub
co-authored by Konstantin Zolin Bohdan Triapitsyn
parent 0776aca9c4
commit dffc4078a1
3 changed files with 216 additions and 181 deletions
+65 -15
View File
@@ -655,7 +655,6 @@ export const Header: React.FC<HeaderProps> = ({
const { t } = useI18n(); const { t } = useI18n();
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const toggleSidebar = useUIStore((state) => state.toggleSidebar); const toggleSidebar = useUIStore((state) => state.toggleSidebar);
const setRightSidebarOpen = useUIStore((state) => state.setRightSidebarOpen);
const toggleBottomTerminal = useUIStore((state) => state.toggleBottomTerminal); const toggleBottomTerminal = useUIStore((state) => state.toggleBottomTerminal);
const toggleRightSidebar = useUIStore((state) => state.toggleRightSidebar); const toggleRightSidebar = useUIStore((state) => state.toggleRightSidebar);
const openContextOverview = useUIStore((state) => state.openContextOverview); const openContextOverview = useUIStore((state) => state.openContextOverview);
@@ -1344,6 +1343,45 @@ export const Header: React.FC<HeaderProps> = ({
const desktopHeaderIconButtonClass = DESKTOP_HEADER_ICON_BUTTON_CLASS; const desktopHeaderIconButtonClass = DESKTOP_HEADER_ICON_BUTTON_CLASS;
const mobileHeaderIconButtonClass = MOBILE_HEADER_ICON_BUTTON_CLASS; const mobileHeaderIconButtonClass = MOBILE_HEADER_ICON_BUTTON_CLASS;
const mobileActiveHeaderItem = React.useMemo(() => {
if (isMobileRateLimitsOpen) {
return 'services';
}
if (leftDrawerOpen) {
return 'sessions';
}
if (rightDrawerOpen) {
return 'git';
}
return activeMainTab;
}, [activeMainTab, isMobileRateLimitsOpen, leftDrawerOpen, rightDrawerOpen]);
const closeMobileHeaderPanels = React.useCallback(() => {
setIsMobileRateLimitsOpen(false);
if (leftDrawerOpen && onToggleLeftDrawer) {
onToggleLeftDrawer();
}
if (rightDrawerOpen && onToggleRightDrawer) {
onToggleRightDrawer();
}
if (!onToggleLeftDrawer && isSessionSwitcherOpen) {
setSessionSwitcherOpen(false);
}
}, [isSessionSwitcherOpen, leftDrawerOpen, onToggleLeftDrawer, onToggleRightDrawer, rightDrawerOpen, setSessionSwitcherOpen]);
const handleMobileLeftDrawerToggle = React.useCallback(() => {
if (!leftDrawerOpen) {
setIsMobileRateLimitsOpen(false);
}
onToggleLeftDrawer?.();
}, [leftDrawerOpen, onToggleLeftDrawer]);
const handleMobileRightDrawerToggle = React.useCallback(() => {
if (!rightDrawerOpen) {
setIsMobileRateLimitsOpen(false);
}
onToggleRightDrawer?.();
}, [onToggleRightDrawer, rightDrawerOpen]);
const desktopPaddingClass = React.useMemo(() => { const desktopPaddingClass = React.useMemo(() => {
if ((isDesktopApp && isMacPlatform && !isDesktopWindowFullscreen) || isTabletStandalonePwa) { if ((isDesktopApp && isMacPlatform && !isDesktopWindowFullscreen) || isTabletStandalonePwa) {
@@ -1629,7 +1667,8 @@ export const Header: React.FC<HeaderProps> = ({
if (num >= 1 && num <= tabs.length) { if (num >= 1 && num <= tabs.length) {
e.preventDefault(); e.preventDefault();
if (isMobile) { if (isMobile) {
setRightSidebarOpen(false); blurActiveElement();
closeMobileHeaderPanels();
} }
setActiveMainTab(tabs[num - 1].id); setActiveMainTab(tabs[num - 1].id);
} }
@@ -1637,7 +1676,7 @@ export const Header: React.FC<HeaderProps> = ({
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [isMobile, setActiveMainTab, setRightSidebarOpen, tabs]); }, [blurActiveElement, closeMobileHeaderPanels, isMobile, setActiveMainTab, tabs]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@@ -1943,10 +1982,10 @@ export const Header: React.FC<HeaderProps> = ({
{onToggleLeftDrawer ? ( {onToggleLeftDrawer ? (
<button <button
type="button" type="button"
onClick={onToggleLeftDrawer} onClick={handleMobileLeftDrawerToggle}
className={cn( className={cn(
mobileHeaderIconButtonClass, mobileHeaderIconButtonClass,
leftDrawerOpen && 'bg-interactive-selection text-interactive-selection-foreground' mobileActiveHeaderItem === 'sessions' && 'bg-interactive-selection text-interactive-selection-foreground'
)} )}
aria-label={leftDrawerOpen ? t('header.actions.closeSessionsAria') : t('header.actions.openSessionsAria')} aria-label={leftDrawerOpen ? t('header.actions.closeSessionsAria') : t('header.actions.openSessionsAria')}
> >
@@ -1972,13 +2011,12 @@ export const Header: React.FC<HeaderProps> = ({
</button> </button>
)} )}
{isSessionSwitcherOpen && ( {!onToggleLeftDrawer && isSessionSwitcherOpen && (
<span className="typography-ui-label font-semibold text-foreground">{t('header.sessions.title')}</span> <span className="typography-ui-label font-semibold text-foreground">{t('header.sessions.title')}</span>
)} )}
</div> </div>
{/* Hide tabs and right-side buttons when sessions sidebar is open */} {(!isSessionSwitcherOpen || Boolean(onToggleLeftDrawer)) && (
{!isSessionSwitcherOpen && (
<> <>
<div className="app-region-no-drag flex min-w-0 flex-1 items-center"> <div className="app-region-no-drag flex min-w-0 flex-1 items-center">
<div className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden scrollbar-hidden touch-pan-x overscroll-x-contain"> <div className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden scrollbar-hidden touch-pan-x overscroll-x-contain">
@@ -2000,7 +2038,7 @@ export const Header: React.FC<HeaderProps> = ({
onClick={() => { onClick={() => {
if (isMobile) { if (isMobile) {
blurActiveElement(); blurActiveElement();
setRightSidebarOpen(false); closeMobileHeaderPanels();
} }
setActiveMainTab(tab.id); setActiveMainTab(tab.id);
}} }}
@@ -2010,7 +2048,7 @@ export const Header: React.FC<HeaderProps> = ({
className={cn( className={cn(
mobileHeaderIconButtonClass, mobileHeaderIconButtonClass,
'relative rounded-lg', 'relative rounded-lg',
isActive && 'bg-interactive-selection text-interactive-selection-foreground' mobileActiveHeaderItem === tab.id && 'bg-interactive-selection text-interactive-selection-foreground'
)} )}
> >
{isDiffTab ? ( {isDiffTab ? (
@@ -2057,6 +2095,14 @@ export const Header: React.FC<HeaderProps> = ({
<DropdownMenu <DropdownMenu
open={isMobileRateLimitsOpen} open={isMobileRateLimitsOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
if (open) {
if (leftDrawerOpen && onToggleLeftDrawer) {
onToggleLeftDrawer();
}
if (rightDrawerOpen && onToggleRightDrawer) {
onToggleRightDrawer();
}
}
setIsMobileRateLimitsOpen(open); setIsMobileRateLimitsOpen(open);
if (open && quotaResults.length === 0) { if (open && quotaResults.length === 0) {
fetchAllQuotas(); fetchAllQuotas();
@@ -2069,7 +2115,10 @@ export const Header: React.FC<HeaderProps> = ({
<button <button
type="button" type="button"
aria-label={t('header.services.viewAria')} aria-label={t('header.services.viewAria')}
className={mobileHeaderIconButtonClass} className={cn(
mobileHeaderIconButtonClass,
mobileActiveHeaderItem === 'services' && 'bg-interactive-selection text-interactive-selection-foreground'
)}
> >
<Icon name="stack" className="h-5 w-5" /> <Icon name="stack" className="h-5 w-5" />
</button> </button>
@@ -2082,10 +2131,11 @@ export const Header: React.FC<HeaderProps> = ({
<DropdownMenuContent <DropdownMenuContent
align="end" align="end"
sideOffset={0} sideOffset={0}
className="h-dvh w-[100vw] max-h-none rounded-none border-0 p-0 overflow-hidden" positionerClassName="!fixed !bottom-0 !left-0 !right-0 !top-[var(--oc-header-height,56px)] !transform-none"
className="h-full w-screen max-h-none rounded-none border-0 p-0 pt-1 overflow-hidden"
> >
<div className="flex h-full flex-col bg-[var(--surface-elevated)]"> <div className="flex h-full flex-col bg-[var(--surface-elevated)]">
<div className="sticky top-0 z-20 bg-[var(--surface-elevated)] px-2 py-px"> <div className="sticky top-0 z-20 bg-[var(--surface-elevated)] px-2 py-px">
<div className="flex items-center justify-between gap-2 px-3 py-0"> <div className="flex items-center justify-between gap-2 px-3 py-0">
<div className="h-10 min-w-0 flex-1"> <div className="h-10 min-w-0 flex-1">
<SortableTabsStrip <SortableTabsStrip
@@ -2324,11 +2374,11 @@ export const Header: React.FC<HeaderProps> = ({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
onClick={onToggleRightDrawer} onClick={handleMobileRightDrawerToggle}
className={cn( className={cn(
mobileHeaderIconButtonClass, mobileHeaderIconButtonClass,
'relative', 'relative',
rightDrawerOpen && 'bg-interactive-selection text-interactive-selection-foreground' mobileActiveHeaderItem === 'git' && 'bg-interactive-selection text-interactive-selection-foreground'
)} )}
aria-label={rightDrawerOpen ? 'Close git sidebar' : 'Open git sidebar'} aria-label={rightDrawerOpen ? 'Close git sidebar' : 'Open git sidebar'}
> >
+148 -165
View File
@@ -1,5 +1,5 @@
import React, { useRef, useEffect } from 'react'; import React, { useRef, useEffect } from 'react';
import { motion, useMotionValue, animate } from 'motion/react'; import { animate, motion, useMotionValue } from 'motion/react';
import { Header } from './Header'; import { Header } from './Header';
import { BottomTerminalDock } from './BottomTerminalDock'; import { BottomTerminalDock } from './BottomTerminalDock';
import { Sidebar, SIDEBAR_CONTENT_WIDTH } from './Sidebar'; import { Sidebar, SIDEBAR_CONTENT_WIDTH } from './Sidebar';
@@ -17,10 +17,10 @@ import { MultiRunLauncher } from '@/components/multirun';
import { DrawerProvider } from '@/contexts/DrawerContext'; import { DrawerProvider } from '@/contexts/DrawerContext';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useUpdateStore } from '@/stores/useUpdateStore'; import { useUpdateStore } from '@/stores/useUpdateStore';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { useVisualViewport } from '@/hooks/useVisualViewport'; import { useVisualViewport } from '@/hooks/useVisualViewport';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
@@ -36,15 +36,12 @@ const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/Sett
const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow }))); 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 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_MIN_WIDTH = 280;
const DESKTOP_SIDEBAR_MAX_WIDTH = 500; const DESKTOP_SIDEBAR_MAX_WIDTH = 500;
const DESKTOP_RIGHT_SIDEBAR_MIN_WIDTH = 360; const DESKTOP_RIGHT_SIDEBAR_MIN_WIDTH = 360;
const DESKTOP_RIGHT_SIDEBAR_MAX_WIDTH = 860; const DESKTOP_RIGHT_SIDEBAR_MAX_WIDTH = 860;
export const MainLayout: React.FC = () => { export const MainLayout: React.FC = () => {
const { t } = useI18n();
const RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH = 1140; const RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH = 1140;
const RIGHT_SIDEBAR_AUTO_OPEN_WIDTH = 1220; const RIGHT_SIDEBAR_AUTO_OPEN_WIDTH = 1220;
const BOTTOM_TERMINAL_AUTO_CLOSE_HEIGHT = 640; const BOTTOM_TERMINAL_AUTO_CLOSE_HEIGHT = 640;
@@ -62,65 +59,158 @@ export const MainLayout: React.FC = () => {
const isMultiRunLauncherOpen = useUIStore((state) => state.isMultiRunLauncherOpen); const isMultiRunLauncherOpen = useUIStore((state) => state.isMultiRunLauncherOpen);
const setMultiRunLauncherOpen = useUIStore((state) => state.setMultiRunLauncherOpen); const setMultiRunLauncherOpen = useUIStore((state) => state.setMultiRunLauncherOpen);
const multiRunLauncherPrefillPrompt = useUIStore((state) => state.multiRunLauncherPrefillPrompt); const multiRunLauncherPrefillPrompt = useUIStore((state) => state.multiRunLauncherPrefillPrompt);
const { isMobile, isTablet } = useDeviceInfo(); const { isMobile, isTablet } = useDeviceInfo();
const visualViewport = useVisualViewport(); const visualViewport = useVisualViewport();
const sidebarWidth = useUIStore((state) => state.sidebarWidth); const sidebarWidth = useUIStore((state) => state.sidebarWidth);
const rightSidebarWidth = useUIStore((state) => state.rightSidebarWidth); const rightSidebarWidth = useUIStore((state) => state.rightSidebarWidth);
const rightSidebarAutoClosedRef = React.useRef(false); const rightSidebarAutoClosedRef = React.useRef(false);
const bottomTerminalAutoClosedRef = React.useRef(false); const bottomTerminalAutoClosedRef = React.useRef(false);
const mobilePanelsResetRef = React.useRef(false);
// Mobile drawer state // Mobile drawer state
const [mobileLeftDrawerOpen, setMobileLeftDrawerOpen] = React.useState(false); 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 mobileRightDrawerOpenRef = React.useRef(false);
const initialDrawerWidthRef = React.useRef(typeof window === 'undefined' ? 0 : window.innerWidth);
// Left drawer motion value // Left drawer motion value
const leftDrawerX = useMotionValue(0); const leftDrawerX = useMotionValue(-initialDrawerWidthRef.current);
const leftDrawerWidth = useRef(0); const leftDrawerWidth = useRef(0);
// Right drawer motion value // Right drawer motion value
const rightDrawerX = useMotionValue(0); const rightDrawerX = useMotionValue(initialDrawerWidthRef.current);
const rightDrawerWidth = useRef(0); const rightDrawerWidth = useRef(0);
// Compute drawer width // Compute drawer width
useEffect(() => { useEffect(() => {
if (isMobile) { if (isMobile) {
leftDrawerWidth.current = window.innerWidth * (MOBILE_DRAWER_WIDTH_PERCENT / 100); leftDrawerWidth.current = window.innerWidth;
rightDrawerWidth.current = window.innerWidth * (MOBILE_DRAWER_WIDTH_PERCENT / 100); rightDrawerWidth.current = window.innerWidth;
} }
}, [isMobile]); }, [isMobile]);
// Sync left drawer state and motion value // Sync left drawer state and motion value
useEffect(() => { useEffect(() => {
if (!isMobile) return; if (!isMobile) {
const targetX = mobileLeftDrawerOpen ? 0 : -leftDrawerWidth.current; setMobileLeftDrawerVisible(false);
animate(leftDrawerX, targetX, { return;
type: "spring", }
if (mobileLeftDrawerOpen) {
setMobileLeftDrawerVisible(true);
}
animate(leftDrawerX, mobileLeftDrawerOpen ? 0 : -leftDrawerWidth.current, {
type: 'spring',
stiffness: 400, stiffness: 400,
damping: 35, damping: 35,
mass: 0.8 mass: 0.8,
}); });
}, [mobileLeftDrawerOpen, isMobile, leftDrawerX]); }, [mobileLeftDrawerOpen, isMobile, leftDrawerX]);
// Sync right drawer state and motion value // Sync right drawer state and motion value
useEffect(() => { useEffect(() => {
if (!isMobile) return; if (!isMobile) {
mobileRightDrawerOpenRef.current = isRightSidebarOpen; setMobileRightDrawerVisible(false);
const targetX = isRightSidebarOpen ? 0 : rightDrawerWidth.current; return;
animate(rightDrawerX, targetX, { }
type: "spring", mobileRightDrawerOpenRef.current = mobileRightSidebarOpen;
if (mobileRightSidebarOpen) {
setMobileRightDrawerVisible(true);
}
animate(rightDrawerX, mobileRightSidebarOpen ? 0 : rightDrawerWidth.current, {
type: 'spring',
stiffness: 400, stiffness: 400,
damping: 35, damping: 35,
mass: 0.8 mass: 0.8,
}); });
}, [isMobile, isRightSidebarOpen, rightDrawerX]); }, [isMobile, mobileRightSidebarOpen, rightDrawerX]);
// Sync session switcher state to left drawer (one-way)
useEffect(() => { useEffect(() => {
if (isMobile) { if (!isMobile) return;
setMobileLeftDrawerOpen(isSessionSwitcherOpen); 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]); }, [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 // Ensure mobile drawers are closed when opening full-screen settings
useEffect(() => { useEffect(() => {
@@ -128,21 +218,12 @@ export const MainLayout: React.FC = () => {
return; return;
} }
setMobileLeftDrawerOpen(false); setMobileSessionPanelOpen(false);
if (isSessionSwitcherOpen) { setMobileRightSidebarOpen(false);
useUIStore.getState().setSessionSwitcherOpen(false);
}
if (isRightSidebarOpen) { if (isRightSidebarOpen) {
setRightSidebarOpen(false); setRightSidebarOpen(false);
} }
}, [isMobile, isSettingsDialogOpen, isSessionSwitcherOpen, isRightSidebarOpen, setRightSidebarOpen]); }, [isMobile, isSettingsDialogOpen, isRightSidebarOpen, setMobileSessionPanelOpen, 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. // Trigger initial update check shortly after mount, then repeat using server-suggested cadence.
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates); const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
@@ -310,10 +391,10 @@ export const MainLayout: React.FC = () => {
const handleToggleMobileRightDrawer = React.useCallback(() => { const handleToggleMobileRightDrawer = React.useCallback(() => {
if (mobileLeftDrawerOpen) { if (mobileLeftDrawerOpen) {
setMobileLeftDrawerOpen(false); setMobileSessionPanelOpen(false);
} }
setRightSidebarOpen(!isRightSidebarOpen); setMobileRightSidebarOpen(!mobileRightSidebarOpen);
}, [isRightSidebarOpen, mobileLeftDrawerOpen, setRightSidebarOpen]); }, [mobileLeftDrawerOpen, mobileRightSidebarOpen, setMobileSessionPanelOpen]);
const secondaryView = React.useMemo(() => { const secondaryView = React.useMemo(() => {
switch (activeMainTab) { switch (activeMainTab) {
@@ -363,150 +444,38 @@ export const MainLayout: React.FC = () => {
{isMobile ? ( {isMobile ? (
<DrawerProvider value={{ <DrawerProvider value={{
leftDrawerOpen: mobileLeftDrawerOpen, leftDrawerOpen: mobileLeftDrawerOpen,
rightDrawerOpen: isRightSidebarOpen, rightDrawerOpen: mobileRightSidebarOpen,
toggleLeftDrawer: () => { toggleLeftDrawer: () => {
if (isRightSidebarOpen) { const nextOpen = !mobileLeftDrawerOpen;
setRightSidebarOpen(false); if (mobileRightSidebarOpen) {
setMobileRightSidebarOpen(false);
} }
setMobileLeftDrawerOpen(!mobileLeftDrawerOpen); setMobileSessionPanelOpen(nextOpen);
}, },
toggleRightDrawer: handleToggleMobileRightDrawer, toggleRightDrawer: handleToggleMobileRightDrawer,
leftDrawerX, leftDrawerX,
rightDrawerX, rightDrawerX,
leftDrawerWidth, leftDrawerWidth,
rightDrawerWidth, rightDrawerWidth,
setMobileLeftDrawerOpen, setMobileLeftDrawerOpen: setMobileSessionPanelOpen,
setRightSidebarOpen, setRightSidebarOpen: setMobileRightSidebarOpen,
}}> }}>
{/* Mobile: header + drawer mode */} {/* Mobile: header + drawer mode */}
{!isSettingsDialogOpen && <Header {!isSettingsDialogOpen && <Header
onToggleLeftDrawer={() => { onToggleLeftDrawer={() => {
if (isRightSidebarOpen) { const nextOpen = !mobileLeftDrawerOpen;
setRightSidebarOpen(false); if (mobileRightSidebarOpen) {
setMobileRightSidebarOpen(false);
} }
setMobileLeftDrawerOpen(!mobileLeftDrawerOpen); setMobileSessionPanelOpen(nextOpen);
}} }}
onToggleRightDrawer={() => { onToggleRightDrawer={() => {
handleToggleMobileRightDrawer(); handleToggleMobileRightDrawer();
}} }}
leftDrawerOpen={mobileLeftDrawerOpen} leftDrawerOpen={mobileLeftDrawerOpen}
rightDrawerOpen={isRightSidebarOpen} rightDrawerOpen={mobileRightSidebarOpen}
/>} />}
{/* Backdrop */}
<motion.button
type="button"
initial={false}
animate={{
opacity: mobileLeftDrawerOpen || isRightSidebarOpen ? 1 : 0,
pointerEvents: mobileLeftDrawerOpen || isRightSidebarOpen ? 'auto' : 'none',
}}
className="fixed left-0 right-0 bottom-0 top-[var(--oc-header-height,56px)] z-40 bg-black/50 cursor-default"
onClick={() => {
setMobileLeftDrawerOpen(false);
setRightSidebarOpen(false);
}}
aria-label={t('mainLayout.mobile.closeDrawerAria')}
/>
{/* Left drawer (Session) */}
<motion.aside
drag="x"
dragElastic={0.08}
dragMomentum={false}
dragConstraints={{ left: -(leftDrawerWidth.current || window.innerWidth * 0.85), right: 0 }}
style={{
width: `${MOBILE_DRAWER_WIDTH_PERCENT}%`,
x: leftDrawerX,
}}
onDragEnd={(_, info) => {
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}
>
<div
data-page-scroll-lock="true"
className="h-full overflow-hidden flex bg-[var(--surface-background)] shadow-none drawer-safe-area"
style={{ backgroundImage: 'linear-gradient(var(--surface-muted), var(--surface-muted))' }}
>
<div className="flex-1 min-w-0 overflow-hidden flex flex-col" data-page-scroll-lock="true">
<ErrorBoundary>
<SessionSidebar mobileVariant />
</ErrorBoundary>
</div>
</div>
</motion.aside>
{/* Right drawer (Git) */}
<motion.aside
drag="x"
dragElastic={0.08}
dragMomentum={false}
dragConstraints={{ left: 0, right: rightDrawerWidth.current || window.innerWidth * 0.85 }}
style={{
width: `${MOBILE_DRAWER_WIDTH_PERCENT}%`,
x: rightDrawerX,
}}
onDragEnd={(_, info) => {
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}
>
<div className="h-full overflow-hidden flex flex-col bg-background shadow-none drawer-safe-area" data-page-scroll-lock="true">
<ErrorBoundary>
<React.Suspense fallback={null}><GitView /></React.Suspense>
</ErrorBoundary>
</div>
</motion.aside>
{/* Main content area (fixed) */} {/* Main content area (fixed) */}
<div <div
data-page-scroll-lock="true" data-page-scroll-lock="true"
@@ -535,6 +504,20 @@ export const MainLayout: React.FC = () => {
</ErrorBoundary> </ErrorBoundary>
</div> </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> </main>
</div> </div>
@@ -94,6 +94,7 @@ type ContentProps = {
side?: "top" | "right" | "bottom" | "left"; side?: "top" | "right" | "bottom" | "left";
alignOffset?: number; alignOffset?: number;
portalToBody?: boolean; portalToBody?: boolean;
positionerClassName?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
className?: string; className?: string;
children?: React.ReactNode; children?: React.ReactNode;
@@ -107,6 +108,7 @@ function DropdownMenuContent({
side, side,
alignOffset, alignOffset,
portalToBody = false, portalToBody = false,
positionerClassName,
style, style,
children, children,
onCloseAutoFocus, onCloseAutoFocus,
@@ -122,7 +124,7 @@ function DropdownMenuContent({
align={align} align={align}
side={side} side={side}
alignOffset={alignOffset} alignOffset={alignOffset}
className="z-50" className={cn("z-50", positionerClassName)}
> >
<BaseMenu.Popup <BaseMenu.Popup
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"