From 70655195f2db05b63eee4765a402b15a8b59a36a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 12 Jun 2026 09:03:40 -0700 Subject: [PATCH] fix: stop iOS chat keyboard collapse from position:fixed scroll lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mobile chat keyboard collapsed the instant it opened on iOS because the keyboard scroll-lock pinned body to position:fixed a beat after the composer was already focused — pinning an ancestor to position:fixed after focus blurs the input on iOS Safari (no visible jump, since the dashboard base layout is already at scrollY 0). Add useMobileKeyboardViewportLock: an overflow-only viewport lock (overflow:hidden on html/body + scrollTo(0,0)) that does NOT touch position, mirroring the working Quick Chat panel. App-level and ChatView keyboard pins use it now; modals keep the position:fixed lock unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fix-ios-chat-keyboard-transform-blur.md | 6 +- packages/dashboard/app/App.tsx | 4 +- .../dashboard/app/components/ChatView.tsx | 9 ++- .../app/hooks/useMobileScrollLock.ts | 68 +++++++++++++++++++ 4 files changed, 81 insertions(+), 6 deletions(-) diff --git a/.changeset/fix-ios-chat-keyboard-transform-blur.md b/.changeset/fix-ios-chat-keyboard-transform-blur.md index 021f567870..0389ce4177 100644 --- a/.changeset/fix-ios-chat-keyboard-transform-blur.md +++ b/.changeset/fix-ios-chat-keyboard-transform-blur.md @@ -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. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index d3f11ac14d..1c298e8555 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -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); diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 616e8563c9..d15aeb38a3 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -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. diff --git a/packages/dashboard/app/hooks/useMobileScrollLock.ts b/packages/dashboard/app/hooks/useMobileScrollLock.ts index 929fd44845..86891ab5fd 100644 --- a/packages/dashboard/app/hooks/useMobileScrollLock.ts +++ b/packages/dashboard/app/hooks/useMobileScrollLock.ts @@ -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 / 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]); } /**