refactor: simplify mobile keyboard handling

Removed fragile global keyboard viewport manager
Restored native mobile viewport behavior
Fixed textarea content shifting outside inputs
This commit is contained in:
Bohdan Triapitsyn
2026-04-26 17:44:33 +03:00
parent be723fa58b
commit 6176ee2b0f
9 changed files with 7 additions and 329 deletions
@@ -792,7 +792,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ 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);
@@ -3303,10 +3302,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
className={cn(
"relative pt-0 pb-4",
isDesktopExpanded && 'flex h-full min-h-0 flex-col pt-4',
isMobile && (isKeyboardOpen ? 'ios-keyboard-safe-area' : 'bottom-safe-area')
isMobile && 'bottom-safe-area'
)}
data-keyboard-avoid="none"
style={isMobile && inputBarOffset > 0 && !isKeyboardOpen ? { marginBottom: `${inputBarOffset}px` } : undefined}
style={isMobile && inputBarOffset > 0 ? { marginBottom: `${inputBarOffset}px` } : undefined}
>
<div className={cn('chat-column relative overflow-visible', isDesktopExpanded && 'flex flex-1 min-h-0 flex-col')}>
<AttachedFilesList />
@@ -20,7 +20,6 @@ import { useUIStore } from '@/stores/useUIStore';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { useDeviceInfo } from '@/lib/device';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useMobileKeyboardManager } from '@/hooks/useMobileKeyboardManager';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { isDesktopShell } from '@/lib/desktop';
@@ -354,8 +353,6 @@ export const MainLayout: React.FC = () => {
};
}, []);
useMobileKeyboardManager(isMobile);
const secondaryView = React.useMemo(() => {
switch (activeMainTab) {
case 'plan':
@@ -387,8 +384,7 @@ export const MainLayout: React.FC = () => {
<DiffWorkerProvider>
<div
className={cn(
'main-content-safe-area',
isMobile ? 'h-full' : 'h-[100dvh]',
'main-content-safe-area h-[100dvh]',
isMobile ? 'flex flex-col' : 'flex',
'bg-background'
)}
@@ -1,262 +0,0 @@
import React from 'react';
import { useUIStore } from '@/stores/useUIStore';
/**
* Detects mobile keyboard open/close via the Visual Viewport API and manages:
* - `--oc-keyboard-inset` CSS variable on :root
* - `--oc-visual-viewport-height` / `--oc-visual-viewport-offset-top` CSS variables
* - `--oc-keyboard-home-indicator` CSS variable (iOS home bar)
* - `--oc-keyboard-avoid-offset` on the active input's keyboard-avoid target
* - `isKeyboardOpen` state in the UI store
*
* Platform-specific handling:
* - iOS: sticky inset with home indicator padding
* - Android: resize-based detection with `maxObservedLayoutHeight` tracking
*/
export function useMobileKeyboardManager(isMobile: boolean): void {
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) {
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);
};
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;
};
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 isTextTarget = isTextInputTarget(active);
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 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]);
}
-4
View File
@@ -223,10 +223,6 @@ textarea[data-chat-input="true"]:focus-visible {
-webkit-appearance: none;
}
[data-keyboard-avoid-active="true"] {
transform: translateY(calc(-1 * var(--oc-keyboard-avoid-offset, 0px)));
}
/* Scroll shadow (HeroUI) fallback styling to ensure visible gradients without the Tailwind plugin */
[data-scroll-shadow="true"] {
--scroll-shadow-size: var(--scroll-shadow-size, 48px);
+3 -15
View File
@@ -32,7 +32,6 @@ const setRootDeviceAttributes = (
isTauriShellRuntime: boolean,
deviceType: DeviceType,
hasTouchInput: boolean,
isAndroid: boolean,
) => {
if (typeof window === 'undefined') {
return;
@@ -42,7 +41,7 @@ const setRootDeviceAttributes = (
const isMobile = deviceType === 'mobile';
const isTablet = deviceType === 'tablet';
root.classList.remove('device-mobile', 'device-tablet', 'device-desktop', 'device-android');
root.classList.remove('device-mobile', 'device-tablet', 'device-desktop');
root.classList.add(
deviceType === 'mobile'
? 'device-mobile'
@@ -61,9 +60,6 @@ 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');
@@ -85,8 +81,6 @@ 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();
const hasTouchInput = prefersCoarsePointer || noHover || maxTouchPoints > 0;
@@ -113,7 +107,7 @@ export function getDeviceInfo(): DeviceInfo {
deviceType = 'desktop';
}
setRootDeviceAttributes(isDesktopShellRuntime, deviceType, hasTouchInput, !isDesktopShellRuntime && isAndroid);
setRootDeviceAttributes(isDesktopShellRuntime, deviceType, hasTouchInput);
let breakpoint: keyof typeof BREAKPOINTS = 'xs';
for (const [key, value] of Object.entries(BREAKPOINTS)) {
@@ -225,14 +219,8 @@ 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,
!isDesktopShellRuntime && isAndroid,
);
setRootDeviceAttributes(isDesktopShellRuntime, deviceInfo.deviceType, hasTouchInput);
}, [deviceInfo.deviceType, deviceInfo.hasTouchInput]);
return deviceInfo;
-7
View File
@@ -482,7 +482,6 @@ interface UIStore {
pendingFileNavigation: PendingFileNavigation | null;
pendingFileFocusPath: string | null;
isMobile: boolean;
isKeyboardOpen: boolean;
isQuickOpenOpen: boolean;
isCommandPaletteOpen: boolean;
isHelpDialogOpen: boolean;
@@ -637,7 +636,6 @@ interface UIStore {
setPadding: (size: number) => void;
setCornerRadius: (radius: number) => void;
setInputBarOffset: (offset: number) => void;
setKeyboardOpen: (open: boolean) => void;
applyTypography: () => void;
applyPadding: () => void;
updateProportionalSidebarWidths: () => void;
@@ -724,7 +722,6 @@ export const useUIStore = create<UIStore>()(
pendingFileNavigation: null,
pendingFileFocusPath: null,
isMobile: false,
isKeyboardOpen: false,
isQuickOpenOpen: false,
isCommandPaletteOpen: false,
isHelpDialogOpen: false,
@@ -1470,10 +1467,6 @@ export const useUIStore = create<UIStore>()(
set({ inputBarOffset: offset });
},
setKeyboardOpen: (open) => {
set((state) => state.isKeyboardOpen === open ? state : { isKeyboardOpen: open });
},
toggleFavoriteModel: (providerID, modelID) => {
set((state) => {
const exists = state.favoriteModels.some(
-3
View File
@@ -9,9 +9,6 @@
--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;
-26
View File
@@ -164,28 +164,6 @@
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;
@@ -288,10 +266,6 @@
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;
+1 -3
View File
@@ -2,9 +2,7 @@
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />
<!-- interactive-widget=resizes-content: tells supporting browsers (Chrome 108+, Safari 16.4+) that the
page handles keyboard-driven viewport resizing itself. Unsupported browsers ignore the token. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />