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:
gsxdsm
2026-06-12 09:03:53 -07:00
4 changed files with 81 additions and 6 deletions

View File

@@ -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.

View File

@@ -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);

View File

@@ -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.

View File

@@ -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]);
}
/**