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:
gsxdsm
2026-06-12 11:39:01 -07:00
parent e5036b13c2
commit 4f3a70aebf
3 changed files with 76 additions and 27 deletions

View File

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

View File

@@ -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);
}
};
}, []);

View File

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