From cbc315772e35498b49e8a5fe934cdae050fe6dd5 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 12 Jun 2026 08:45:27 -0700 Subject: [PATCH] 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) --- .../fix-ios-chat-keyboard-transform-blur.md | 5 ++ .changeset/fix-quick-chat-fab-ios-open.md | 5 ++ .../dashboard/app/components/ChatView.css | 16 +++-- .../dashboard/app/components/ChatView.tsx | 20 ++++++ .../dashboard/app/components/QuickChatFAB.tsx | 61 +++++++++++++++++-- 5 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 .changeset/fix-ios-chat-keyboard-transform-blur.md create mode 100644 .changeset/fix-quick-chat-fab-ios-open.md diff --git a/.changeset/fix-ios-chat-keyboard-transform-blur.md b/.changeset/fix-ios-chat-keyboard-transform-blur.md new file mode 100644 index 0000000000..021f567870 --- /dev/null +++ b/.changeset/fix-ios-chat-keyboard-transform-blur.md @@ -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. diff --git a/.changeset/fix-quick-chat-fab-ios-open.md b/.changeset/fix-quick-chat-fab-ios-open.md new file mode 100644 index 0000000000..75ba36468b --- /dev/null +++ b/.changeset/fix-quick-chat-fab-ios-open.md @@ -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. diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index f2d97623ef..bb6570fa80 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -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. */ diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index aadd154295..616e8563c9 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -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]); diff --git a/packages/dashboard/app/components/QuickChatFAB.tsx b/packages/dashboard/app/components/QuickChatFAB.tsx index adc28925f2..6a403bf878 100644 --- a/packages/dashboard/app/components/QuickChatFAB.tsx +++ b/packages/dashboard/app/components/QuickChatFAB.tsx @@ -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) { +function useDraggable( + projectId?: string, + externalDidDragRef?: React.MutableRefObject, + 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(null); const hideSkillMenuTimeoutRef = useRef(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 ( <>