From 87db2ea2101fcd9a969a0b21b36c44f7b3a65892 Mon Sep 17 00:00:00 2001 From: vhqtvn Date: Sun, 26 Apr 2026 00:58:40 +0700 Subject: [PATCH] fix(ui): stabilize mobile keyboard viewport handling (#1026) * fix(ui): stabilize mobile keyboard viewport handling * fix(ui): tighten mobile keyboard viewport handling * Avoid redundant keyboard open store writes --------- Co-authored-by: vhqtvn <8930337+vhqtvn@users.noreply.github.com> Co-authored-by: Bohdan Triapitsyn --- packages/ui/src/components/chat/ChatInput.tsx | 7 +- .../ui/src/components/layout/MainLayout.tsx | 252 +++++++++++++++++- packages/ui/src/index.css | 4 + packages/ui/src/lib/device.ts | 17 +- packages/ui/src/stores/useUIStore.ts | 7 + packages/ui/src/styles/design-system.css | 5 + packages/ui/src/styles/mobile.css | 29 +- packages/web/index.html | 2 +- 8 files changed, 313 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 924a7e59..34e86c40 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -786,6 +786,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const agents = getVisibleAgents(); const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]); const isMobile = useUIStore((state) => state.isMobile); + const isKeyboardOpen = useUIStore((state) => state.isKeyboardOpen); const inputBarOffset = useUIStore((state) => state.inputBarOffset); const persistChatDraft = useUIStore((state) => state.persistChatDraft); const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled); @@ -3291,10 +3292,10 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo className={cn( "relative pt-0 pb-4", isDesktopExpanded && 'flex h-full min-h-0 flex-col pt-4', - isMobile && 'bottom-safe-area' + isMobile && (isKeyboardOpen ? 'ios-keyboard-safe-area' : 'bottom-safe-area') )} - - style={isMobile && inputBarOffset > 0 ? { marginBottom: `${inputBarOffset}px` } : undefined} + data-keyboard-avoid="none" + style={isMobile && inputBarOffset > 0 && !isKeyboardOpen ? { marginBottom: `${inputBarOffset}px` } : undefined} >
diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index e47c1e45..b5887269 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -351,6 +351,255 @@ export const MainLayout: React.FC = () => { }; }, []); + React.useEffect(() => { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + const root = document.documentElement; + + let stickyKeyboardInset = 0; + let ignoreOpenUntilZero = false; + let previousHeight = 0; + let maxObservedLayoutHeight = 0; + let previousOrientation = ''; + let keyboardAvoidTarget: HTMLElement | null = null; + + const setKeyboardOpen = useUIStore.getState().setKeyboardOpen; + const userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent; + const isAndroid = /Android/i.test(userAgent); + const isIOS = /iPad|iPhone|iPod/.test(userAgent); + + 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 explicitTargetId = active.getAttribute('data-keyboard-avoid-target-id'); + if (explicitTargetId) { + const explicitTarget = document.getElementById(explicitTargetId); + if (explicitTarget instanceof HTMLElement) { + return explicitTarget; + } + } + const markedTarget = active.closest('[data-keyboard-avoid]') as HTMLElement | null; + if (markedTarget) { + // data-keyboard-avoid="none" opts out of translateY avoidance entirely. + // Used by components with their own scroll (e.g. CodeMirror). + if (markedTarget.getAttribute('data-keyboard-avoid') === 'none') { + return null; + } + 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); + }; + + let rafId = 0; + + 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; + const orientation = window.innerWidth >= window.innerHeight ? 'landscape' : 'portrait'; + + root.style.setProperty('--oc-visual-viewport-offset-top', `${offsetTop}px`); + root.style.setProperty('--oc-visual-viewport-height', `${height}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); + if (previousOrientation !== orientation) { + previousOrientation = orientation; + maxObservedLayoutHeight = layoutHeight; + } else if (layoutHeight > maxObservedLayoutHeight || maxObservedLayoutHeight === 0) { + maxObservedLayoutHeight = layoutHeight; + } + const viewportSum = height + offsetTop; + const rawInset = Math.max(0, layoutHeight - viewportSum); + const rawAndroidResizeInset = isAndroid + ? Math.max(0, maxObservedLayoutHeight - layoutHeight) + : 0; + + const openThreshold = isTextTarget ? 120 : 180; + const measuredInset = rawInset >= openThreshold ? rawInset : 0; + const androidResizeInset = isTextTarget && rawAndroidResizeInset >= openThreshold + ? rawAndroidResizeInset + : 0; + const effectiveMeasuredInset = Math.max(measuredInset, androidResizeInset); + + if (ignoreOpenUntilZero) { + if (effectiveMeasuredInset === 0) { + ignoreOpenUntilZero = false; + } + stickyKeyboardInset = 0; + } else if (stickyKeyboardInset === 0) { + if (effectiveMeasuredInset > 0 && isTextTarget) { + stickyKeyboardInset = effectiveMeasuredInset; + setKeyboardOpen(true); + } + } else { + const closingByHeight = !isTextTarget && height > previousHeight + 6; + + if (effectiveMeasuredInset === 0) { + stickyKeyboardInset = 0; + setKeyboardOpen(false); + } else if (closingByHeight) { + forceKeyboardClosed(); + } else if (effectiveMeasuredInset > 0 && isTextTarget) { + stickyKeyboardInset = effectiveMeasuredInset; + setKeyboardOpen(true); + } else if (effectiveMeasuredInset > stickyKeyboardInset) { + stickyKeyboardInset = effectiveMeasuredInset; + setKeyboardOpen(true); + } + } + + root.style.setProperty('--oc-keyboard-inset', `${stickyKeyboardInset}px`); + previousHeight = height; + + 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, effectiveMeasuredInset); + 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'); + } + } + + if (isMobile && isTextTarget) { + const scroller = document.scrollingElement; + if (scroller && scroller.scrollTop !== 0) { + scroller.scrollTop = 0; + } + if (window.scrollY !== 0) { + window.scrollTo(0, 0); + } + } + }; + + const scheduleVisualViewportUpdate = () => { + if (rafId) return; + rafId = requestAnimationFrame(() => { + rafId = 0; + updateVisualViewport(); + }); + }; + + updateVisualViewport(); + + const viewport = window.visualViewport; + viewport?.addEventListener('resize', scheduleVisualViewportUpdate); + viewport?.addEventListener('scroll', scheduleVisualViewportUpdate); + window.addEventListener('resize', scheduleVisualViewportUpdate); + window.addEventListener('orientationchange', scheduleVisualViewportUpdate); + const isTextInputTarget = (element: HTMLElement | null) => { + if (!element) { + return false; + } + const tagName = element.tagName; + const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT'; + return isInput || element.isContentEditable; + }; + + const handleFocusIn = (event: FocusEvent) => { + const target = event.target as HTMLElement | null; + if (isTextInputTarget(target)) { + ignoreOpenUntilZero = false; + } + scheduleVisualViewportUpdate(); + }; + document.addEventListener('focusin', handleFocusIn, true); + + const handleFocusOut = (event: FocusEvent) => { + const target = event.target as HTMLElement | null; + if (!isTextInputTarget(target)) { + return; + } + + const related = event.relatedTarget as HTMLElement | null; + if (isTextInputTarget(related)) { + return; + } + + window.requestAnimationFrame(() => { + if (isTextInputTarget(document.activeElement as HTMLElement | null)) { + return; + } + + const currentViewport = window.visualViewport; + const height = currentViewport ? Math.round(currentViewport.height) : window.innerHeight; + const offsetTop = currentViewport ? Math.max(0, Math.round(currentViewport.offsetTop)) : 0; + const layoutHeight = Math.round(root.clientHeight || window.innerHeight); + const viewportSum = height + offsetTop; + const rawInset = Math.max(0, layoutHeight - viewportSum); + + if (rawInset > 0) { + updateVisualViewport(); + return; + } + + forceKeyboardClosed(); + updateVisualViewport(); + }); + }; + + document.addEventListener('focusout', handleFocusOut, true); + + return () => { + if (rafId) cancelAnimationFrame(rafId); + viewport?.removeEventListener('resize', scheduleVisualViewportUpdate); + viewport?.removeEventListener('scroll', scheduleVisualViewportUpdate); + window.removeEventListener('resize', scheduleVisualViewportUpdate); + window.removeEventListener('orientationchange', scheduleVisualViewportUpdate); + document.removeEventListener('focusin', handleFocusIn, true); + document.removeEventListener('focusout', handleFocusOut, true); + clearKeyboardAvoidTarget(); + }; + }, [isMobile]); + const secondaryView = React.useMemo(() => { switch (activeMainTab) { case 'plan': @@ -382,7 +631,8 @@ export const MainLayout: React.FC = () => {
{ if (typeof window === 'undefined') { return; @@ -41,7 +42,7 @@ const setRootDeviceAttributes = ( const isMobile = deviceType === 'mobile'; const isTablet = deviceType === 'tablet'; - root.classList.remove('device-mobile', 'device-tablet', 'device-desktop'); + root.classList.remove('device-mobile', 'device-tablet', 'device-desktop', 'device-android'); root.classList.add( deviceType === 'mobile' ? 'device-mobile' @@ -60,6 +61,9 @@ const setRootDeviceAttributes = ( root.classList.remove('mobile-pointer'); } else { root.classList.remove('desktop-runtime'); + if (isAndroid) { + root.classList.add('device-android'); + } root.style.setProperty('--is-mobile', isMobile ? '1' : '0'); root.style.setProperty('--device-type', deviceType); root.style.setProperty('--font-scale', isMobile ? '0.9' : isTablet ? '0.95' : '1'); @@ -81,6 +85,7 @@ export function getDeviceInfo(): DeviceInfo { const prefersCoarsePointer = pointerQuery?.matches ?? false; const noHover = hoverQuery?.matches ?? false; const maxTouchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints ?? 0 : 0; + const isAndroid = typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent); const isDesktopShellRuntime = isDesktopShell(); @@ -108,7 +113,7 @@ export function getDeviceInfo(): DeviceInfo { deviceType = 'desktop'; } - setRootDeviceAttributes(isDesktopShellRuntime, deviceType, hasTouchInput); + setRootDeviceAttributes(isDesktopShellRuntime, deviceType, hasTouchInput, !isDesktopShellRuntime && isAndroid); let breakpoint: keyof typeof BREAKPOINTS = 'xs'; for (const [key, value] of Object.entries(BREAKPOINTS)) { @@ -220,8 +225,14 @@ export function useDeviceInfo(): DeviceInfo { const prefersCoarsePointer = pointerQuery?.matches ?? false; const noHover = hoverQuery?.matches ?? false; const maxTouchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints ?? 0 : 0; + const isAndroid = typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent); const hasTouchInput = prefersCoarsePointer || noHover || maxTouchPoints > 0; - setRootDeviceAttributes(isDesktopShellRuntime, deviceInfo.deviceType, hasTouchInput); + setRootDeviceAttributes( + isDesktopShellRuntime, + deviceInfo.deviceType, + hasTouchInput, + !isDesktopShellRuntime && isAndroid, + ); }, [deviceInfo.deviceType, deviceInfo.hasTouchInput]); return deviceInfo; diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 829cae20..0cceaff4 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -482,6 +482,7 @@ interface UIStore { pendingFileNavigation: PendingFileNavigation | null; pendingFileFocusPath: string | null; isMobile: boolean; + isKeyboardOpen: boolean; isQuickOpenOpen: boolean; isCommandPaletteOpen: boolean; isHelpDialogOpen: boolean; @@ -636,6 +637,7 @@ interface UIStore { setPadding: (size: number) => void; setCornerRadius: (radius: number) => void; setInputBarOffset: (offset: number) => void; + setKeyboardOpen: (open: boolean) => void; applyTypography: () => void; applyPadding: () => void; updateProportionalSidebarWidths: () => void; @@ -722,6 +724,7 @@ export const useUIStore = create()( pendingFileNavigation: null, pendingFileFocusPath: null, isMobile: false, + isKeyboardOpen: false, isQuickOpenOpen: false, isCommandPaletteOpen: false, isHelpDialogOpen: false, @@ -1467,6 +1470,10 @@ export const useUIStore = create()( set({ inputBarOffset: offset }); }, + setKeyboardOpen: (open) => { + set((state) => state.isKeyboardOpen === open ? state : { isKeyboardOpen: open }); + }, + toggleFavoriteModel: (providerID, modelID) => { set((state) => { const exists = state.favoriteModels.some( diff --git a/packages/ui/src/styles/design-system.css b/packages/ui/src/styles/design-system.css index a0ebd5db..0f1d5c72 100644 --- a/packages/ui/src/styles/design-system.css +++ b/packages/ui/src/styles/design-system.css @@ -7,6 +7,11 @@ --oc-safe-area-bottom-visual: 0px; --oc-safe-area-left: 0px; --oc-header-height: 56px; + --oc-visual-viewport-offset-top: 0px; + --oc-visual-viewport-height: 100dvh; + --oc-keyboard-inset: 0px; + --oc-keyboard-avoid-offset: 0px; + --oc-keyboard-home-indicator: 0px; --oc-wco-left-inset: 0px; --oc-wco-right-inset: 0px; --oc-wco-titlebar-height: 0px; diff --git a/packages/ui/src/styles/mobile.css b/packages/ui/src/styles/mobile.css index 3d29f185..ea7c2af0 100644 --- a/packages/ui/src/styles/mobile.css +++ b/packages/ui/src/styles/mobile.css @@ -158,13 +158,34 @@ } /* Fix mobile viewport height */ - :root.mobile-pointer:not(.desktop-runtime), - :root.mobile-pointer:not(.desktop-runtime) body { + :root.mobile-pointer:not(.desktop-runtime) { height: 100%; height: -webkit-fill-available; overflow: hidden; } + :root.device-mobile:not(.desktop-runtime) body, + :root.device-mobile:not(.desktop-runtime) #root { + position: fixed; + top: 0; + left: 0; + right: 0; + height: var(--oc-visual-viewport-height, 100dvh); + width: 100%; + overflow: hidden; + overscroll-behavior-y: none; + } + + :root.device-android:not(.desktop-runtime) body, + :root.device-android:not(.desktop-runtime) #root { + position: relative; + top: auto; + left: auto; + right: auto; + height: 100%; + min-height: 0; + } + /* Fix main layout container */ :root.mobile-pointer:not(.desktop-runtime) .flex.flex-col.h-screen { height: 100vh; @@ -267,6 +288,10 @@ padding-bottom: var(--oc-safe-area-bottom-visual); } + :root.device-mobile:not(.desktop-runtime) .ios-keyboard-safe-area { + padding-bottom: calc(var(--oc-keyboard-home-indicator, 34px) + var(--oc-safe-area-bottom-visual, 0px)) !important; + } + /* Fix iOS viewport issues */ :root.device-mobile:not(.desktop-runtime) .flex.flex-col.h-screen { min-height: 100vh; diff --git a/packages/web/index.html b/packages/web/index.html index 662d1203..b63b2fe9 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -2,7 +2,7 @@ - +