Merge: fix iOS chat keyboard collapse via overflow-only viewport lock
Replace the position:fixed body pin for the inline chat composer with an overflow-only viewport lock so iOS no longer blurs the focused textarea when the keyboard opens. Modals keep the position:fixed lock. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,4 +2,8 @@
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix the mobile chat keyboard collapsing the instant it opens on iOS Safari. `.chat-thread--keyboard-active` declared `transform: translateY(...)` + `will-change: transform` in CSS, keeping a non-`none` transform on `.chat-thread` — an ancestor of the focused composer textarea — for the whole keyboard-active window. iOS treats establishing that containing block over a focused input as a reason to blur it, dismissing the keyboard right after focus (with no visible jump, since `--vv-offset-top` is 0 at that moment). The drift compensation is now applied imperatively in JS only when iOS actually shifts the visual viewport (`offsetTop > 0`), so the ancestor stays `transform: none` on focus and the keyboard stays up.
|
||||
Fix the mobile chat keyboard collapsing the instant it opens on iOS Safari. Two ancestor mutations were blurring the focused composer textarea:
|
||||
|
||||
1. `.chat-thread--keyboard-active` declared `transform: translateY(...)` + `will-change: transform` in CSS, keeping a non-`none` transform on `.chat-thread` (an ancestor of the composer) for the whole keyboard-active window. The drift compensation is now applied imperatively in JS only when iOS actually shifts the visual viewport (`offsetTop > 0`), so the ancestor stays `transform: none` on focus.
|
||||
|
||||
2. The mobile keyboard scroll-lock pinned `body { position: fixed }` a beat after the composer was focused — the textbook iOS keyboard-dismiss trigger. App-level and ChatView keyboard pins now use a new `useMobileKeyboardViewportLock` that locks `overflow: hidden` + `scrollTo(0, 0)` WITHOUT changing `position` (the same approach the Quick Chat panel uses), so iOS keeps the input focused. Modals are unchanged and keep the `position: fixed` lock.
|
||||
|
||||
@@ -63,7 +63,7 @@ import { useDeepLink } from "./hooks/useDeepLink";
|
||||
import { useFavorites } from "./hooks/useFavorites";
|
||||
import { useAuthOnboarding } from "./hooks/useAuthOnboarding";
|
||||
import { useMobileKeyboard } from "./hooks/useMobileKeyboard";
|
||||
import { isIOS, useMobileScrollLock } from "./hooks/useMobileScrollLock";
|
||||
import { isIOS, useMobileKeyboardViewportLock } from "./hooks/useMobileScrollLock";
|
||||
import { computeMobileBarKeyboardFlags } from "./utils/mobileBarKeyboardFlags";
|
||||
import { useSetupReadiness } from "./hooks/useSetupReadiness";
|
||||
import { useUpdateCheck } from "./hooks/useUpdateCheck";
|
||||
@@ -541,7 +541,7 @@ function AppInner() {
|
||||
// shift the document or visualViewport, and so the dashboard snaps back
|
||||
// into place when the keyboard dismisses. Modals manage their own lock
|
||||
// via useMobileScrollLock — the reference-counted hook handles overlap.
|
||||
useMobileScrollLock(mobileKeyboardOpen);
|
||||
useMobileKeyboardViewportLock(mobileKeyboardOpen);
|
||||
|
||||
// App-level mailbox/chat unread state (used for header/mobile nav badges)
|
||||
const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0);
|
||||
|
||||
@@ -43,7 +43,7 @@ import { useModelsCache } from "../hooks/useModelsCache";
|
||||
import { useDiscoveredSkillsCache } from "../hooks/useDiscoveredSkillsCache";
|
||||
import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
import { useMobileScrollLock, isIOS } from "../hooks/useMobileScrollLock";
|
||||
import { useMobileKeyboardViewportLock, isIOS } from "../hooks/useMobileScrollLock";
|
||||
import { matchesAgentMentionFilter } from "./mentionMatching";
|
||||
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
|
||||
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||||
@@ -1588,9 +1588,12 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
}, [keyboardOverlap, scrollToBottom]);
|
||||
|
||||
// Lock body scroll on mobile while the keyboard is up so iOS can't shift
|
||||
// the visual viewport (offsetTop > 0). Shared hook also restores
|
||||
// the visual viewport (offsetTop > 0). Uses the overflow-only keyboard
|
||||
// lock (NOT position:fixed): the composer is focused before the lock
|
||||
// applies, and pinning body to position:fixed afterwards blurs the input
|
||||
// on iOS, collapsing the keyboard the instant it opens. Restores
|
||||
// window.scrollTo(0, 0) on cleanup to recover from any iOS drift.
|
||||
useMobileScrollLock(isMobile && keyboardOpen);
|
||||
useMobileKeyboardViewportLock(isMobile && keyboardOpen);
|
||||
|
||||
// FN-5365: mirror QuickChatFAB keyboard handling by writing visualViewport
|
||||
// metrics directly to .chat-thread, avoiding React commit lag/jitter.
|
||||
|
||||
@@ -124,6 +124,74 @@ function releaseLock(): void {
|
||||
export function _resetLockState(): void {
|
||||
lockCount = 0;
|
||||
savedStyles = null;
|
||||
kbLockCount = 0;
|
||||
kbSavedStyles = null;
|
||||
}
|
||||
|
||||
// --- Keyboard viewport lock (non-blurring variant) -------------------------
|
||||
//
|
||||
// The `position: fixed` lock above is correct for fullscreen overlays whose
|
||||
// input is focused AFTER the lock is applied (modals). It is WRONG for the
|
||||
// inline chat composer: there the input is focused FIRST (the tap raises the
|
||||
// keyboard), and pinning `body { position: fixed }` a beat later — once
|
||||
// `keyboardOpen` flips true — makes iOS Safari blur the focused textarea and
|
||||
// collapse the keyboard the instant it opens (no visible jump, because the
|
||||
// dashboard's base layout is already at scrollY 0).
|
||||
//
|
||||
// This variant mirrors the QuickChat overlay's proven approach: lock
|
||||
// `overflow: hidden` on <html>/<body> and snap scroll to the top, WITHOUT
|
||||
// touching `position`. No position change → iOS keeps the input focused, so
|
||||
// the keyboard stays up. Independent ref-count from the modal lock so the two
|
||||
// never interfere.
|
||||
let kbLockCount = 0;
|
||||
let kbSavedStyles: {
|
||||
htmlOverflow: string;
|
||||
bodyOverflow: string;
|
||||
} | null = null;
|
||||
|
||||
function applyKeyboardLock(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
if (kbLockCount > 0) {
|
||||
kbLockCount += 1;
|
||||
return;
|
||||
}
|
||||
const html = document.documentElement;
|
||||
const body = document.body;
|
||||
kbSavedStyles = {
|
||||
htmlOverflow: html.style.overflow,
|
||||
bodyOverflow: body.style.overflow,
|
||||
};
|
||||
window.scrollTo(0, 0);
|
||||
html.style.overflow = "hidden";
|
||||
body.style.overflow = "hidden";
|
||||
kbLockCount = 1;
|
||||
}
|
||||
|
||||
function releaseKeyboardLock(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
if (kbLockCount === 0) return;
|
||||
kbLockCount -= 1;
|
||||
if (kbLockCount > 0 || !kbSavedStyles) return;
|
||||
document.documentElement.style.overflow = kbSavedStyles.htmlOverflow;
|
||||
document.body.style.overflow = kbSavedStyles.bodyOverflow;
|
||||
kbSavedStyles = null;
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin the mobile viewport while the soft keyboard is up for an INLINE
|
||||
* (non-overlay) focused input — chat composer, inline edits. Uses an
|
||||
* overflow-only lock that does not change `position`, so iOS does not blur
|
||||
* the already-focused input. iOS-only; no-op on desktop/Android.
|
||||
*/
|
||||
export function useMobileKeyboardViewportLock(enabled: boolean): void {
|
||||
useEffect(() => {
|
||||
if (!enabled || !isMobileDevice() || !isIOS()) return;
|
||||
applyKeyboardLock();
|
||||
return () => {
|
||||
releaseKeyboardLock();
|
||||
};
|
||||
}, [enabled]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user