fix: keep mobile chat keyboard up on re-focus and hide nav while typing
Direct-chat composer no longer scrolls on focus (which aborted the iOS keyboard raise on every tap after a dismiss). Layout-viewport drift is now undone on blur — immediately plus a short follow-up cancelled on the next focus — so each focus starts at scrollY 0 and the keyboard lock's scrollTo is a no-op. Also hide the mobile bottom nav while the keyboard is up: it previously pinned to bottom:0 and relied on the keyboard to cover it, but on iOS the layout viewport does not shrink, so the bar overlapped the composer. It now slides off-screen (translateY(100%) + pointer-events:none); safe because the nav is a sibling of the input, not an ancestor. Folds into the existing iOS chat-keyboard changeset. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<string, boolean>;
|
||||
}
|
||||
|
||||
// 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<number | null>(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<number | null>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const pendingAttachmentsRef = useRef<PendingAttachment[]>([]);
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user