From 48af84222ab8196a080e16bf8b1a6b44de3b47f1 Mon Sep 17 00:00:00 2001 From: Junlang Huang <37657807+Dav1dch@users.noreply.github.com> Date: Mon, 18 May 2026 23:05:13 +0800 Subject: [PATCH] fix: prevent mobile keyboard from occluding terminal viewport (#1303) * fix: prevent mobile keyboard from occluding terminal viewport On mobile, 100dvh does not shrink when the soft keyboard opens, causing the terminal and other absolute-inset-0 views to extend behind the keyboard. Add useVisualViewport hook that tracks window.visualViewport.resize/scroll via RAF. On mobile, use the visual viewport height (actual visible area) for the root container instead of 100dvh, so content resizes above the keyboard when it opens. * fix: skip unchanged visual viewport updates --------- Co-authored-by: Bohdan Triapitsyn --- .../ui/src/components/layout/MainLayout.tsx | 7 ++- packages/ui/src/hooks/useVisualViewport.ts | 52 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/hooks/useVisualViewport.ts diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index 893794a8..fc257279 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -20,6 +20,7 @@ import { useUIStore } from '@/stores/useUIStore'; import { useUpdateStore } from '@/stores/useUpdateStore'; import { useDeviceInfo } from '@/lib/device'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; +import { useVisualViewport } from '@/hooks/useVisualViewport'; import { useI18n } from '@/lib/i18n'; import { cn } from '@/lib/utils'; import { isDesktopShell } from '@/lib/desktop'; @@ -84,6 +85,7 @@ export const MainLayout: React.FC = () => { const multiRunLauncherPrefillPrompt = useUIStore((state) => state.multiRunLauncherPrefillPrompt); const { isMobile, isTablet } = useDeviceInfo(); + const visualViewport = useVisualViewport(); const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []); const sidebarWidth = useUIStore((state) => state.sidebarWidth); const rightSidebarWidth = useUIStore((state) => state.rightSidebarWidth); @@ -390,10 +392,11 @@ export const MainLayout: React.FC = () => {
0 ? { height: visualViewport.height } : undefined} > diff --git a/packages/ui/src/hooks/useVisualViewport.ts b/packages/ui/src/hooks/useVisualViewport.ts new file mode 100644 index 00000000..b45a493a --- /dev/null +++ b/packages/ui/src/hooks/useVisualViewport.ts @@ -0,0 +1,52 @@ +import React from 'react'; + +export interface VisualViewportState { + height: number; + keyboardHeight: number; +} + +const getInitialHeight = (): number => { + if (typeof window === 'undefined') return 0; + return window.visualViewport?.height ?? window.innerHeight; +}; + +export const useVisualViewport = (): VisualViewportState => { + const [state, setState] = React.useState(() => ({ + height: getInitialHeight(), + keyboardHeight: 0, + })); + + const rafIdRef = React.useRef(null); + + React.useEffect(() => { + if (typeof window === 'undefined' || !window.visualViewport) return; + + const handleChange = () => { + if (rafIdRef.current !== null) return; + rafIdRef.current = requestAnimationFrame(() => { + rafIdRef.current = null; + const vv = window.visualViewport!; + const height = vv.height; + const keyboardHeight = Math.max(0, window.innerHeight - height); + setState((prev) => { + if (prev.height === height && prev.keyboardHeight === keyboardHeight) return prev; + return { height, keyboardHeight }; + }); + }); + }; + + window.visualViewport.addEventListener('resize', handleChange); + window.visualViewport.addEventListener('scroll', handleChange, { passive: true }); + handleChange(); + + return () => { + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + } + window.visualViewport?.removeEventListener('resize', handleChange); + window.visualViewport?.removeEventListener('scroll', handleChange); + }; + }, []); + + return state; +};