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:
5
.changeset/fix-ios-chat-keyboard-transform-blur.md
Normal file
5
.changeset/fix-ios-chat-keyboard-transform-blur.md
Normal 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.
|
||||
5
.changeset/fix-quick-chat-fab-ios-open.md
Normal file
5
.changeset/fix-quick-chat-fab-ios-open.md
Normal 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.
|
||||
@@ -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. */
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user