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;
+};