diff --git a/.changeset/fix-ios-chat-keyboard-transform-blur.md b/.changeset/fix-ios-chat-keyboard-transform-blur.md index 0389ce4177..e7820a3aba 100644 --- a/.changeset/fix-ios-chat-keyboard-transform-blur.md +++ b/.changeset/fix-ios-chat-keyboard-transform-blur.md @@ -2,8 +2,12 @@ "@runfusion/fusion": patch --- -Fix the mobile chat keyboard collapsing the instant it opens on iOS Safari. Two ancestor mutations were blurring the focused composer textarea: +Fix the mobile chat keyboard collapsing on iOS Safari. Several ancestor/scroll 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. + +3. The direct-chat composer's `handleInputFocus` ran `window.scrollTo(0, 0)` on every focus to undo iOS layout drift. That scroll fires while iOS is still raising the keyboard, which aborts the raise — the keyboard opened then immediately dismissed on re-focus (first tap fine, every tap after a dismiss broken). The drift reset now happens on **blur** instead — when the keyboard is already closing, so there is nothing to dismiss — immediately plus a short follow-up that is cancelled on the next focus, so a fast re-tap can't scroll mid-raise. Each focus therefore starts at `scrollY 0` and the keyboard lock's `scrollTo(0, 0)` is a harmless no-op. + +4. The mobile bottom nav stayed on screen while the keyboard was up: `.mobile-nav-bar--keyboard-open` only pinned it to `bottom: 0` and relied on the keyboard to cover it, but on iOS the layout viewport doesn't shrink, so the bar overlapped the composer. It now slides fully off-screen (`translateY(100%)` + `pointer-events: none`) while typing. Safe for the keyboard because the nav is a sibling of the input, not an ancestor. diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 7679431468..64d1bea9e1 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -48,13 +48,6 @@ import { matchesAgentMentionFilter } from "./mentionMatching"; import { useNavigationHistoryContext } from "../hooks/useNavigationHistory"; import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; -import { - CHAT_INPUT_MAX_HEIGHT_PX, - TABLET_INPUT_MAX_HEIGHT_PX, - clampChatInputHeight, - resolveChatInputOverflowY, -} from "../utils/chatInputAutosize"; -export { clampChatInputHeight, resolveChatInputOverflowY } from "../utils/chatInputAutosize"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; @@ -64,10 +57,28 @@ export interface ChatViewProps { experimentalFeatures?: Record; } +// Keep a generous cap so pasted multi-paragraph text stays visible while +// still preventing the composer from overtaking the message pane on short viewports. +const CHAT_INPUT_MAX_HEIGHT_PX = 640; +const TABLET_INPUT_MAX_HEIGHT_PX = 200; /** Canonical definition lives in packages/dashboard/src/chat.ts (ROOM_SKIP_SENTINEL). */ const ROOM_SKIP_SENTINEL = "__SKIP__"; let chatViewWasPreviouslyInactive = false; +export function resolveChatInputOverflowY( + scrollHeight: number, + maxHeight: number = CHAT_INPUT_MAX_HEIGHT_PX, +): "auto" | "hidden" { + return scrollHeight > maxHeight ? "auto" : "hidden"; +} + +export function clampChatInputHeight(scrollHeight: number, maxHeight: number = CHAT_INPUT_MAX_HEIGHT_PX): number { + // Floor matches QuickChat (clampQuickChatInputHeight) and the CSS min-height, + // so a 0-scrollHeight measurement (e.g. before layout) still yields a + // sensible inline height instead of collapsing the composer to 0. + return Math.max(40, Math.min(scrollHeight, maxHeight)); +} + function formatRelativeTime(dateStr: string, t: TFunction<"app">): string { const date = new Date(dateStr); const now = new Date(); @@ -1077,6 +1088,9 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView // visualViewport shrink samples do not jerk the chat thread/composer. const suppressVvShrinkRef = useRef(false); const suppressVvShrinkTimeoutRef = useRef(null); + // Deferred drift-reset scheduled on blur; cancelled on the next focus so a + // quick re-tap never scrolls the document while iOS is raising the keyboard. + const blurScrollResetTimeoutRef = useRef(null); const inputRef = useRef(null); const fileInputRef = useRef(null); const pendingAttachmentsRef = useRef([]); @@ -2281,6 +2295,31 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView suppressVvShrinkRef.current = false; suppressVvShrinkTimeoutRef.current = null; }, 450); + + // Undo iOS layout-viewport drift HERE, on blur, not on the next focus. + // After a keyboard dismiss iOS can leave window.scrollY > 0; if that + // residual scroll is still present on the next focus, the keyboard + // lock's scrollTo(0,0) fires a *real* scroll while iOS is raising the + // keyboard and dismisses it (the "second tap dismisses" regression). + // Resetting on blur — when the keyboard is already closing, so there is + // nothing to dismiss — means the next focus starts at scrollY 0 and the + // lock's scroll is a no-op. We reset immediately and once more after the + // dismiss animation settles (iOS can re-drift mid-animation). The + // deferred reset is cancelled on focus so a fast re-tap can't scroll + // mid-raise. + if (window.scrollY !== 0 || window.scrollX !== 0) { + window.scrollTo(0, 0); + } + if (blurScrollResetTimeoutRef.current !== null) { + window.clearTimeout(blurScrollResetTimeoutRef.current); + } + blurScrollResetTimeoutRef.current = window.setTimeout(() => { + blurScrollResetTimeoutRef.current = null; + if (document.activeElement?.tagName === "TEXTAREA") return; + if (window.scrollY !== 0 || window.scrollX !== 0) { + window.scrollTo(0, 0); + } + }, 350); } if (hideSkillMenuTimeoutRef.current !== null) { @@ -2308,22 +2347,19 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView window.clearTimeout(hideSkillMenuTimeoutRef.current); hideSkillMenuTimeoutRef.current = null; } - // iOS quirk: after the keyboard has been dismissed once, re-focusing - // an input leaves window.scrollY > 0 *and* visualViewport.offsetTop - // > 0 — the layout viewport drifts up, and the position:fixed - // useMobileScrollLock applies to a body that is no longer at the - // top of the document. Result: the message thread anchors above - // the visible viewport with a large blank area below it. Forcing - // scroll back to (0,0) on the focus event neutralizes the drift - // before lock applies. Done in a microtask so iOS finishes its - // own scroll-into-view first. - if (typeof window !== "undefined" && window.innerWidth <= 768) { - queueMicrotask(() => { - if (window.scrollY !== 0 || window.scrollX !== 0) { - window.scrollTo(0, 0); - } - }); + // Cancel any deferred blur drift-reset: it would scroll the document while + // iOS is raising the keyboard for THIS focus and dismiss it. + if (blurScrollResetTimeoutRef.current !== null) { + window.clearTimeout(blurScrollResetTimeoutRef.current); + blurScrollResetTimeoutRef.current = null; } + // NOTE: deliberately no window.scrollTo(0,0) here. Scrolling on the focus + // event fires while iOS is still raising the soft keyboard, and iOS treats + // a programmatic scroll mid-raise as a reason to abort it — the keyboard + // opens then immediately dismisses, so the input can't be typed in. This + // mirrors QuickChatFAB's handleInputFocus, which does not scroll and works. + // Drift is instead reset on blur (see handleInputBlur), so by the time this + // focus runs the document is already at scrollY 0. }, []); useEffect(() => { @@ -2331,6 +2367,9 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView if (suppressVvShrinkTimeoutRef.current !== null) { window.clearTimeout(suppressVvShrinkTimeoutRef.current); } + if (blurScrollResetTimeoutRef.current !== null) { + window.clearTimeout(blurScrollResetTimeoutRef.current); + } }; }, []); diff --git a/packages/dashboard/app/components/MobileNavBar.css b/packages/dashboard/app/components/MobileNavBar.css index 61ae3aaf11..2ead65917e 100644 --- a/packages/dashboard/app/components/MobileNavBar.css +++ b/packages/dashboard/app/components/MobileNavBar.css @@ -44,13 +44,19 @@ bottom: var(--icb-bottom-offset, 0px); } - /* When the on-screen keyboard is open, ignore the visualViewport - compensation that would otherwise push the bar up by the keyboard - height (iOS shrinks vv.height; bottomOffset becomes ~keyboard height). - Pin the nav to the page bottom so the keyboard simply covers it. */ + /* When the on-screen keyboard is open, hide the nav outright instead of + pinning it to bottom:0 and trusting the keyboard to cover it. On iOS the + layout viewport does NOT shrink, so a bottom:0 bar stays on screen and + overlaps the composer that has lifted above the keyboard. Sliding it fully + off the bottom edge guarantees it's gone while typing. Safe for the + keyboard: the nav is a sibling of the chat input, not an ancestor, so the + transform does not establish a containing block over the focused field + (which is what would otherwise make iOS collapse the keyboard). */ .mobile-nav-bar.mobile-nav-bar--keyboard-open, .mobile-nav-bar.mobile-nav-bar--with-footer.mobile-nav-bar--keyboard-open { bottom: 0; + transform: translateY(100%); + pointer-events: none; } /* Content padding: mobile nav only (no footer). Mirrors the visible fixed stack,