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 <artmore@protonmail.com>
This commit is contained in:
Junlang Huang
2026-05-18 18:05:13 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 76eef473aa
commit 48af84222a
2 changed files with 57 additions and 2 deletions
@@ -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 = () => {
<div
data-page-scroll-lock="true"
className={cn(
'main-content-safe-area h-[100dvh]',
isMobile ? 'flex flex-col' : 'flex',
'main-content-safe-area',
isMobile ? 'flex flex-col' : 'flex h-[100dvh]',
'bg-background'
)}
style={isMobile && visualViewport.height > 0 ? { height: visualViewport.height } : undefined}
>
<CommandPalette />
<HelpDialog />
@@ -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<VisualViewportState>(() => ({
height: getInitialHeight(),
keyboardHeight: 0,
}));
const rafIdRef = React.useRef<number | null>(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;
};