fix: keep iOS mobile chat keyboard up and reopen Quick Chat FAB

Two iOS Safari-specific fixes for mobile chat:

- ChatView: the main chat keyboard collapsed the instant it opened
  because .chat-thread--keyboard-active declared transform/will-change in
  CSS, keeping a non-none transform on an ancestor of the focused
  composer textarea. iOS blurs a focused input when an ancestor
  establishes a transform containing block. Drive the drift transform in
  JS only when iOS actually shifts the viewport (offsetTop > 0); the
  ancestor stays transform:none on focus so the keyboard stays up.

- QuickChatFAB: the FAB never opened on iPhone because the drag hook
  calls setPointerCapture() in pointerdown, which makes WebKit swallow
  the synthetic click. Fire the open/close toggle from the drag hook's
  pointerup on a tap (a real gesture, so stealth-input focus still raises
  the keyboard); keep onClick for mouse/tests with a timer-cleared dedupe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-12 08:45:27 -07:00
parent ab040885fb
commit cbc315772e
5 changed files with 96 additions and 11 deletions

View File

@@ -0,0 +1,5 @@
---
"@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.

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix the Quick Chat FAB not opening on iOS Safari. The drag hook calls `setPointerCapture()` in `pointerdown`, which makes WebKit swallow the synthetic `click`, so the FAB never toggled on iPhone. The open/close toggle now fires from the drag hook's `pointerup` (a real user gesture, so the stealth-input focus still raises the keyboard), with the trailing synthetic click de-duped so mouse and test click paths are unaffected.

View File

@@ -1758,12 +1758,16 @@
.chat-thread--keyboard-active {
height: calc(var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) - var(--header-height));
max-height: calc(var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) - var(--header-height));
/* Re-anchored: useMobileKeyboard now only updates --vv-offset-top
on resize/focus transitions (not on every visualViewport scroll
during a pan), so the transform tracks the keyboard open/close
without jittering during a swipe. */
transform: translateY(var(--vv-offset-top, 0px));
will-change: transform;
/* NOTE: the translateY drift compensation is applied imperatively in
JS (see ChatView's vv `apply()`), NOT here. Declaring
`transform`/`will-change: transform` in CSS keeps a non-`none`
transform on .chat-thread for the entire keyboard-active window —
and since .chat-thread is an ancestor of the focused composer
textarea, iOS Safari treats establishing that containing block as a
reason to blur the input and collapse the keyboard the instant it
opens. JS only sets a transform when there is real viewport drift
(offsetTop > 0); at the focus moment offsetTop is 0, so the
ancestor stays `transform: none` and the keyboard stays up. */
}
/* On mobile, the active scope affordance uses a full-width pinned footer. */

View File

@@ -1613,6 +1613,8 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
const apply = () => {
if (suppressVvShrinkRef.current) {
thread.classList.remove("chat-thread--keyboard-active");
thread.style.transform = "";
thread.style.willChange = "";
return;
}
const overlap = Math.max(0, window.innerHeight - vv.offsetTop - vv.height);
@@ -1623,6 +1625,22 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
const keyboardActive = (overlap > 0 || offsetTop > 0) && isKeyboardTrackingFocusable(document.activeElement);
thread.classList.toggle("chat-thread--keyboard-active", keyboardActive);
// Drift compensation is applied here (not in CSS) so .chat-thread —
// an ancestor of the focused composer textarea — only gets a
// non-`none` transform when iOS actually shifts the visual viewport
// (offsetTop > 0). Keeping a transform/will-change on it at all times
// (as the old CSS did) makes iOS Safari blur the input and collapse
// the keyboard the moment it opens, because at focus time offsetTop
// is 0 and translateY(0) still establishes a containing block over
// the focused element.
if (keyboardActive && offsetTop > 0) {
thread.style.transform = `translateY(${offsetTop}px)`;
thread.style.willChange = "transform";
} else {
thread.style.transform = "";
thread.style.willChange = "";
}
};
apply();
@@ -1640,6 +1658,8 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
window.removeEventListener("pageshow", apply);
document.removeEventListener("visibilitychange", apply);
thread.classList.remove("chat-thread--keyboard-active");
thread.style.transform = "";
thread.style.willChange = "";
};
}, [activeSession, isMobile, roomThreadActive]);

View File

@@ -371,7 +371,16 @@ const QUICK_CHAT_VIEWPORT_PADDING = 8;
* @param projectId - Optional project ID for localStorage key
* @param externalDidDragRef - External ref to track drag state for click detection
*/
function useDraggable(projectId?: string, externalDidDragRef?: React.MutableRefObject<boolean>) {
function useDraggable(
projectId?: string,
externalDidDragRef?: React.MutableRefObject<boolean>,
onTap?: () => void,
) {
// Latest onTap kept in a ref so the imperatively-bound document
// pointerup handler always calls the current closure without forcing
// listener re-binds.
const onTapRef = useRef(onTap);
onTapRef.current = onTap;
// Get executor footer height from CSS variable
const getFooterHeight = useCallback((): number => {
if (typeof window === "undefined") return 0;
@@ -475,6 +484,12 @@ function useDraggable(projectId?: string, externalDidDragRef?: React.MutableRefO
if (didDragRef.current) {
savePosition(positionRef.current);
} else {
// A tap (not a drag). Fire the toggle from pointerup rather than
// relying on the synthetic click: iOS Safari suppresses the click
// when setPointerCapture() was called in pointerdown (a WebKit
// quirk), so onClick alone never opens the panel on iPhone.
onTapRef.current?.();
}
document.removeEventListener("pointermove", handleDocumentPointerMove);
@@ -998,13 +1013,21 @@ export function QuickChatFAB({
const hideMentionPopupTimeoutRef = useRef<number | null>(null);
const hideSkillMenuTimeoutRef = useRef<number | null>(null);
const dragDepthRef = useRef(0);
// Set by the latest tap handler (defined further down, after isOpen /
// stealthInputRef exist). Indirection keeps the useDraggable call above
// those declarations.
const fabTapHandlerRef = useRef<(() => void) | null>(null);
// True for ~the click-delay window after a pointerup tap fired the
// toggle, so the trailing synthetic click (when iOS does emit one)
// doesn't double-toggle.
const suppressNextFabClickRef = useRef(false);
// Draggable hook for FAB positioning
const {
position,
isDragging,
handlePointerDown,
} = useDraggable(projectId, didDragRef);
} = useDraggable(projectId, didDragRef, () => fabTapHandlerRef.current?.());
// Panel stays 60px above FAB (FAB is 48px tall + 12px gap)
const panelY = position.y + 60;
@@ -2427,9 +2450,9 @@ export function QuickChatFAB({
],
);
// Handle FAB click - only toggle if this was a click (not a drag)
// Reset didDragRef after checking to prevent double-toggle
const handleFABClick = useCallback(() => {
// Core open/close toggle. Only toggles if this was a tap (not a drag);
// resets didDragRef after checking to prevent a double-toggle.
const toggleQuickChat = useCallback(() => {
if (didDragRef.current) {
// Was a drag, don't toggle
didDragRef.current = false;
@@ -2452,6 +2475,34 @@ export function QuickChatFAB({
setIsOpen(true);
}, [isOpen, setIsOpen]);
// Fired from the drag hook's pointerup when the gesture was a tap, not a
// drag. This is the reliable open path on iOS: setPointerCapture() in
// pointerdown makes iOS Safari swallow the synthetic click, so onClick
// alone never opens the panel on iPhone. pointerup is itself a user
// gesture, so the stealth-input focus inside toggleQuickChat still
// raises the keyboard.
const handleFABTap = useCallback(() => {
suppressNextFabClickRef.current = true;
if (typeof window !== "undefined") {
window.setTimeout(() => {
suppressNextFabClickRef.current = false;
}, 500);
}
toggleQuickChat();
}, [toggleQuickChat]);
fabTapHandlerRef.current = handleFABTap;
// Synthetic click path — still used for mouse (where pointerup also
// fires handleFABTap, so we de-dupe) and for click-only callers like
// tests (no preceding pointerup tap, so we handle it).
const handleFABClick = useCallback(() => {
if (suppressNextFabClickRef.current) {
suppressNextFabClickRef.current = false;
return;
}
toggleQuickChat();
}, [toggleQuickChat]);
return (
<>
<input