From fc2f4746e551a919108eaab32f09c69bd9ab2a2e Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 3 May 2026 21:39:30 +0300 Subject: [PATCH] fix: avoid iPad PWA traffic light overlap Adds tablet standalone PWA detection Reuses macOS traffic-light header spacing Protects session sidebar and multirun headers --- packages/ui/src/components/layout/Header.tsx | 13 ++-- .../components/multirun/MultiRunLauncher.tsx | 8 ++- .../src/components/session/SessionSidebar.tsx | 6 +- .../session/sidebar/SidebarHeader.tsx | 4 +- packages/ui/src/lib/device.ts | 63 +++++++++++++++++++ 5 files changed, 83 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 18025c0b..8983849f 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -34,7 +34,7 @@ import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; -import { useDeviceInfo } from '@/lib/device'; +import { useDeviceInfo, useTabletStandalonePwaRuntime } from '@/lib/device'; import { cn, hasModifier } from '@/lib/utils'; import { McpDropdownContent } from '@/components/mcp/McpDropdown'; import { McpIcon } from '@/components/icons/McpIcon'; @@ -720,6 +720,7 @@ export const Header: React.FC = ({ } return isDesktopShell(); }); + const isTabletStandalonePwa = useTabletStandalonePwaRuntime(); const [isDesktopWindowFullscreen, setIsDesktopWindowFullscreen] = React.useState(false); const isMacPlatform = React.useMemo(() => { @@ -1328,11 +1329,11 @@ export const Header: React.FC = ({ const mobileHeaderIconButtonClass = MOBILE_HEADER_ICON_BUTTON_CLASS; const desktopPaddingClass = React.useMemo(() => { - if (!isSidebarOpen && isDesktopApp && isMacPlatform && !isDesktopWindowFullscreen) { + if (!isSidebarOpen && ((isDesktopApp && isMacPlatform && !isDesktopWindowFullscreen) || isTabletStandalonePwa)) { return 'pl-[5.5rem]'; } return 'pl-3'; - }, [isDesktopApp, isDesktopWindowFullscreen, isMacPlatform, isSidebarOpen]); + }, [isDesktopApp, isDesktopWindowFullscreen, isMacPlatform, isSidebarOpen, isTabletStandalonePwa]); useEffect(() => { if (!isDesktopApp || !isMacPlatform) { @@ -1400,12 +1401,14 @@ export const Header: React.FC = ({ } return { - paddingLeft: 'calc(0.75rem + var(--oc-wco-left-inset, 0px))', + paddingLeft: isTabletStandalonePwa + ? 'max(calc(0.75rem + var(--oc-wco-left-inset, 0px)), 5.5rem)' + : 'calc(0.75rem + var(--oc-wco-left-inset, 0px))', paddingRight: 'calc(0.75rem + var(--oc-wco-right-inset, 0px))', minHeight: 'max(3rem, var(--oc-wco-titlebar-height, 0px))', height: 'max(3rem, var(--oc-wco-titlebar-height, 0px))', }; - }, [isDesktopApp, isVSCode]); + }, [isDesktopApp, isTabletStandalonePwa, isVSCode]); const updateHeaderHeight = React.useCallback(() => { if (typeof document === 'undefined') { diff --git a/packages/ui/src/components/multirun/MultiRunLauncher.tsx b/packages/ui/src/components/multirun/MultiRunLauncher.tsx index 004f9ce3..ba96fd31 100644 --- a/packages/ui/src/components/multirun/MultiRunLauncher.tsx +++ b/packages/ui/src/components/multirun/MultiRunLauncher.tsx @@ -22,6 +22,7 @@ import { AgentSelector } from './AgentSelector'; import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } from '@/components/chat/CommandAutocomplete'; import { FileMentionAutocomplete, type FileMentionHandle } from '@/components/chat/FileMentionAutocomplete'; import { isDesktopShell } from '@/lib/desktop'; +import { useTabletStandalonePwaRuntime } from '@/lib/device'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta'; import type { ProjectEntry } from '@/lib/api/types'; @@ -215,6 +216,7 @@ export const MultiRunLauncher: React.FC = ({ } return /Macintosh|Mac OS X/.test(navigator.userAgent || ''); }, []); + const isTabletStandalonePwa = useTabletStandalonePwaRuntime(); React.useEffect(() => { if (typeof window === 'undefined') { @@ -250,12 +252,12 @@ export const MultiRunLauncher: React.FC = ({ }, []); const desktopHeaderPaddingClass = React.useMemo(() => { - if (isDesktopApp && isMacPlatform) { - // Match main app header: reserve space for Mac traffic lights. + if ((isDesktopApp && isMacPlatform) || isTabletStandalonePwa) { + // Match main app header: reserve space for Mac/iPadOS traffic lights. return 'pl-[5.5rem]'; } return 'pl-3'; - }, [isDesktopApp, isMacPlatform]); + }, [isDesktopApp, isMacPlatform, isTabletStandalonePwa]); const macosHeaderSizeClass = React.useMemo(() => { if (!isDesktopApp || !isMacPlatform || macosMajorVersion === null) { diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 0f63ece7..20312015 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -4,7 +4,7 @@ import { RiLayoutLeftLine } from '@remixicon/react'; import { toast } from '@/components/ui'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useI18n } from '@/lib/i18n'; -import { useDeviceInfo } from '@/lib/device'; +import { useDeviceInfo, useTabletStandalonePwaRuntime } from '@/lib/device'; import { isDesktopShell } from '@/lib/desktop'; import { isDesktopWindowFullscreen as getDesktopWindowFullscreen, onDesktopWindowResized, startDesktopWindowDrag } from '@/lib/desktopNative'; import { sessionEvents } from '@/lib/sessionEvents'; @@ -435,6 +435,7 @@ export const SessionSidebar: React.FC = ({ }, []); const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []); + const isTabletStandalonePwa = useTabletStandalonePwaRuntime(); const [isDesktopWindowFullscreen, setIsDesktopWindowFullscreen] = React.useState(false); const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); @@ -448,7 +449,7 @@ export const SessionSidebar: React.FC = ({ }, []); const isWebRuntime = !mobileVariant && !isVSCode && !isDesktopShellRuntime; const showDesktopSidebarChrome = !mobileVariant && !isVSCode && !isWebRuntime; - const desktopSidebarTopPaddingClass = isDesktopShellRuntime && isMacPlatform && !isDesktopWindowFullscreen ? 'pl-[5.5rem]' : 'pl-3'; + const desktopSidebarTopPaddingClass = (isDesktopShellRuntime && isMacPlatform && !isDesktopWindowFullscreen) || isTabletStandalonePwa ? 'pl-[5.5rem]' : 'pl-3'; const desktopSidebarToggleButtonClass = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center rounded-md typography-ui-label font-medium text-foreground transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50'; React.useEffect(() => { @@ -1680,6 +1681,7 @@ export const SessionSidebar: React.FC = ({ onToggleSelectionMode={handleToggleSelectionMode} showSidebarToggle={isWebRuntime} onToggleSidebar={toggleSidebar} + avoidWindowControlsOverlay={isTabletStandalonePwa} /> void; showSidebarToggle?: boolean; onToggleSidebar?: () => void; + avoidWindowControlsOverlay?: boolean; }; export function SidebarHeader(props: Props): React.ReactNode { @@ -75,6 +76,7 @@ export function SidebarHeader(props: Props): React.ReactNode { onToggleSelectionMode, showSidebarToggle = false, onToggleSidebar, + avoidWindowControlsOverlay = false, } = props; const displayMode = useSessionDisplayStore((state) => state.displayMode); @@ -90,7 +92,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
{reserveHeaderActionsSpace ? ( diff --git a/packages/ui/src/lib/device.ts b/packages/ui/src/lib/device.ts index 62c9322e..7e85449e 100644 --- a/packages/ui/src/lib/device.ts +++ b/packages/ui/src/lib/device.ts @@ -141,6 +141,69 @@ export function isMobileDeviceViaCSS(): boolean { return isMobileValue === '1' || isMobileValue === 'true'; } +export const isStandalonePwaRuntime = (): boolean => { + if (typeof window === 'undefined') return false; + + const standaloneNavigator = navigator as Navigator & { standalone?: boolean }; + return Boolean( + standaloneNavigator.standalone === true + || window.matchMedia?.('(display-mode: standalone)')?.matches + || window.matchMedia?.('(display-mode: fullscreen)')?.matches + ); +}; + +export const isTabletStandalonePwaRuntime = (): boolean => { + if (typeof window === 'undefined' || isDesktopShell()) return false; + + const maxTouchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints ?? 0 : 0; + return isStandalonePwaRuntime() && maxTouchPoints > 0 && window.innerWidth > BREAKPOINTS.md; +}; + +export function useTabletStandalonePwaRuntime(): boolean { + const [value, setValue] = React.useState(() => isTabletStandalonePwaRuntime()); + + React.useEffect(() => { + if (typeof window === 'undefined') return; + + const update = () => setValue(isTabletStandalonePwaRuntime()); + const standaloneQuery = window.matchMedia?.('(display-mode: standalone)'); + const fullscreenQuery = window.matchMedia?.('(display-mode: fullscreen)'); + + update(); + window.addEventListener('resize', update); + window.addEventListener('focus', update); + + const addQueryListener = (query: MediaQueryList | undefined) => { + if (!query) return; + if (typeof query.addEventListener === 'function') { + query.addEventListener('change', update); + } else if (typeof query.addListener === 'function') { + query.addListener(update); + } + }; + const removeQueryListener = (query: MediaQueryList | undefined) => { + if (!query) return; + if (typeof query.removeEventListener === 'function') { + query.removeEventListener('change', update); + } else if (typeof query.removeListener === 'function') { + query.removeListener(update); + } + }; + + addQueryListener(standaloneQuery); + addQueryListener(fullscreenQuery); + + return () => { + window.removeEventListener('resize', update); + window.removeEventListener('focus', update); + removeQueryListener(standaloneQuery); + removeQueryListener(fullscreenQuery); + }; + }, []); + + return value; +} + export function useDeviceInfo(): DeviceInfo { const [deviceInfo, setDeviceInfo] = React.useState(() => { if (typeof window === 'undefined') {