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:
committed by
GitHub
co-authored by
Konstantin Zolin
Bohdan Triapitsyn
parent
0776aca9c4
commit
dffc4078a1
@@ -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'}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user