From 93237c3f0c69ec1f3c81f16081cad17a15f8848e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 12 Jun 2026 10:27:24 -0700 Subject: [PATCH] FN-6301: preserve mobile chat composer native focus Preserve native mobile composer focus so first taps open the keyboard and sends remain usable. - Stop canceling unfocused mobile textarea touch starts in direct chat, room chat, and Quick Chat. - Add regression coverage for iOS and Android native focus behavior across the affected composers. - Add a patch changeset for the published Fusion package. Files changed: .changeset/fn-6301-mobile-chat-composer.md | 5 ++ packages/dashboard/app/components/ChatView.tsx | 16 ++--- packages/dashboard/app/components/QuickChatFAB.tsx | 18 +---- .../components/__tests__/ChatView.rooms.test.tsx | 23 +++++-- .../app/components/__tests__/ChatView.test.tsx | 45 +++++++++++++ .../app/components/__tests__/QuickChatFAB.test.tsx | 77 ++++++++++++++++++++++ 6 files changed, 153 insertions(+), 31 deletions(-) Fusion-Task-Id: FN-6301 Fusion-Task-Lineage: 3bee944d-3b94-41a2-976e-c1e3221236ad --- .changeset/fn-6301-mobile-chat-composer.md | 5 ++ .../dashboard/app/components/ChatView.tsx | 16 ++-- .../dashboard/app/components/QuickChatFAB.tsx | 18 +---- .../__tests__/ChatView.rooms.test.tsx | 23 ++++-- .../components/__tests__/ChatView.test.tsx | 45 +++++++++++ .../__tests__/QuickChatFAB.test.tsx | 77 +++++++++++++++++++ 6 files changed, 153 insertions(+), 31 deletions(-) create mode 100644 .changeset/fn-6301-mobile-chat-composer.md diff --git a/.changeset/fn-6301-mobile-chat-composer.md b/.changeset/fn-6301-mobile-chat-composer.md new file mode 100644 index 0000000000..c065f75783 --- /dev/null +++ b/.changeset/fn-6301-mobile-chat-composer.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix mobile chat composer first taps so iOS and Android preserve native keyboard focus across direct chat, room chat, and Quick Chat. diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index c847d96710..7679431468 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -2880,8 +2880,9 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView if (window.innerWidth > 768) return; if (!isIOS()) return; if (document.activeElement === event.currentTarget) return; - event.preventDefault(); - event.currentTarget.focus({ preventScroll: true }); + // FN-6301: do not preventDefault on the first unfocused iOS tap. + // Native focus is the reliable path that raises the soft keyboard; + // the visualViewport/input-focus effects own scroll compensation. }} rows={1} data-testid="chat-input" @@ -3420,16 +3421,11 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView onTouchStart={(event) => { if (typeof window === "undefined") return; if (window.innerWidth > 768) return; - // iOS-only: preventDefault + programmatic focus avoids - // iOS's visual-viewport scroll on re-focus. On Android, - // preventDefault here blocks the soft keyboard from - // opening at all (programmatic focus() does not raise - // the keyboard on Android), so the input "focuses" but - // the keyboard never appears. if (!isIOS()) return; if (document.activeElement === event.currentTarget) return; - event.preventDefault(); - event.currentTarget.focus({ preventScroll: true }); + // FN-6301: do not preventDefault on the first unfocused iOS tap. + // Native focus is the reliable path that raises the soft keyboard; + // the visualViewport/input-focus effects own scroll compensation. }} rows={1} data-testid="chat-input" diff --git a/packages/dashboard/app/components/QuickChatFAB.tsx b/packages/dashboard/app/components/QuickChatFAB.tsx index 6a403bf878..996b770209 100644 --- a/packages/dashboard/app/components/QuickChatFAB.tsx +++ b/packages/dashboard/app/components/QuickChatFAB.tsx @@ -3027,26 +3027,14 @@ export function QuickChatFAB({ onBlur={handleInputBlur} onFocus={handleInputFocus} onPaste={handlePaste} - // Intercept the touch *before* iOS's default focus-and- - // scroll handler runs. Without this, on the second focus - // (after a keyboard dismiss) iOS shifts the visual - // viewport to "scroll" the input into view, which yanks - // the position:fixed panel up off-screen for ~1s before - // settling back. preventDefault on touchstart suppresses - // that auto-scroll; we then focus programmatically with - // preventScroll so the keyboard still comes up. onTouchStart={(event) => { if (typeof window === "undefined") return; if (window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return; - // iOS-only workaround. On Android, preventDefault on - // textarea touchstart prevents the soft keyboard from - // opening at all (programmatic focus() does not raise - // the keyboard on Android — only the default touch - // action does), so the tap silently dismisses. if (!isIOS()) return; if (document.activeElement === event.currentTarget) return; - event.preventDefault(); - event.currentTarget.focus({ preventScroll: true }); + // FN-6301: do not preventDefault on the first unfocused iOS tap. + // Native focus is the reliable path that raises the soft keyboard; + // the visualViewport/input-focus effects own scroll compensation. }} placeholder={inputPlaceholder} disabled={inputDisabled} diff --git a/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx index bd2a9cf1f7..88dc9e37a7 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx @@ -11,6 +11,7 @@ import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard"; vi.mock("../../hooks/useChat"); vi.mock("../../hooks/useMobileScrollLock", () => ({ useMobileScrollLock: vi.fn(), + useMobileKeyboardViewportLock: vi.fn(), isIOS: () => true, _resetLockState: vi.fn(), })); @@ -605,20 +606,30 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { await renderWithAct(); const roomInput = screen.getByTestId("chat-input") as HTMLTextAreaElement; - const roomFocusSpy = vi.spyOn(roomInput, "focus"); + const roomTouchEvent = new TouchEvent("touchstart", { bubbles: true, cancelable: true }); + const roomPreventDefaultSpy = vi.spyOn(roomTouchEvent, "preventDefault"); await act(async () => { - fireEvent.touchStart(roomInput); + fireEvent(roomInput, roomTouchEvent); + if (!roomTouchEvent.defaultPrevented) { + roomInput.focus(); + } }); - expect(roomFocusSpy).toHaveBeenCalledWith({ preventScroll: true }); + expect(roomPreventDefaultSpy).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(roomInput); await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct")); const directInput = screen.getByTestId("chat-input") as HTMLTextAreaElement; - const directFocusSpy = vi.spyOn(directInput, "focus"); + const directTouchEvent = new TouchEvent("touchstart", { bubbles: true, cancelable: true }); + const directPreventDefaultSpy = vi.spyOn(directTouchEvent, "preventDefault"); await act(async () => { - fireEvent.touchStart(directInput); + fireEvent(directInput, directTouchEvent); + if (!directTouchEvent.defaultPrevented) { + directInput.focus(); + } }); - expect(directFocusSpy).toHaveBeenCalledWith({ preventScroll: true }); + expect(directPreventDefaultSpy).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(directInput); mediaSpy.mockRestore(); }); diff --git a/packages/dashboard/app/components/__tests__/ChatView.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.test.tsx index 2ac69b93f4..ec7db73740 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.test.tsx @@ -22,6 +22,7 @@ import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard"; import { SWR_CACHE_KEYS, writeCache } from "../../utils/swrCache"; import * as useChatRoomsModule from "../../hooks/useChatRooms"; import type { UseChatRoomsResult } from "../../hooks/useChatRooms"; +import * as mobileScrollLock from "../../hooks/useMobileScrollLock"; // Mock the hooks vi.mock("../../hooks/useChat"); @@ -3869,6 +3870,50 @@ describe("ChatView mobile behavior", () => { } }); + it("mobile mode: iOS first tap focuses direct composer without blocking native focus, then sends", async () => { + const restoreMatchMedia = mockMobileViewport(); + const isIOSSpy = vi.spyOn(mobileScrollLock, "isIOS").mockReturnValue(true); + const sendMessage = vi.fn(); + + try { + setupMockChat({ + activeSession: activeSessionFixture, + messages: [], + sendMessage, + }); + + await renderWithAct(); + + const input = screen.getByTestId("chat-input") as HTMLTextAreaElement; + input.blur(); + expect(document.activeElement).not.toBe(input); + + const touchEvent = new TouchEvent("touchstart", { bubbles: true, cancelable: true }); + const preventDefaultSpy = vi.spyOn(touchEvent, "preventDefault"); + fireEvent(input, touchEvent); + // jsdom has no soft keyboard/native touch-focus default action; mirror + // the browser focus that iOS only performs when touchstart is not canceled. + if (!touchEvent.defaultPrevented) { + input.focus(); + } + + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(input); + + fireEvent.change(input, { target: { value: "Hello mobile" } }); + const sendButton = screen.getByTestId("chat-send-btn"); + fireEvent.touchStart(sendButton); + fireEvent.click(sendButton); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith("Hello mobile", []); + expect(document.activeElement).toBe(input); + } finally { + isIOSSpy.mockRestore(); + restoreMatchMedia.mockRestore(); + } + }); + it("mobile mode: send button sends on first touch and keeps composer focused", async () => { const restoreMatchMedia = mockMobileViewport(); const sendMessage = vi.fn(); diff --git a/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx b/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx index 0b0ac4364e..1a0b91497a 100644 --- a/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx @@ -9,6 +9,7 @@ import { useViewportMode } from "../../hooks/useViewportMode"; import { useMobileKeyboard } from "../../hooks/useMobileKeyboard"; import { useAppSettings } from "../../hooks/useAppSettings"; import { useChatRooms } from "../../hooks/useChatRooms"; +import * as mobileScrollLock from "../../hooks/useMobileScrollLock"; import { QuickChatFAB } from "../QuickChatFAB"; import { FileBrowserProvider } from "../../context/FileBrowserContext"; @@ -799,6 +800,82 @@ describe("QuickChatFAB session-first UX", () => { expect(screen.getByTestId("quick-chat-session-option-session-model")).toBeInTheDocument(); }); + it("FN-6301: iOS first tap focuses composer without canceling native focus, then sends", async () => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); + window.dispatchEvent(new Event("resize")); + mockUseViewportMode.mockReturnValue("mobile"); + const isIOSSpy = vi.spyOn(mobileScrollLock, "isIOS").mockReturnValue(true); + mockStreamChatResponse.mockImplementation((_sessionId, _content, _handlers) => ({ + close: vi.fn(), + isConnected: () => true, + })); + + try { + render(); + fireEvent.click(screen.getByTestId("quick-chat-fab")); + + const input = await screen.findByTestId("quick-chat-input") as HTMLTextAreaElement; + await waitFor(() => expect(input).not.toBeDisabled()); + input.blur(); + expect(document.activeElement).not.toBe(input); + + const touchEvent = new TouchEvent("touchstart", { bubbles: true, cancelable: true }); + const preventDefaultSpy = vi.spyOn(touchEvent, "preventDefault"); + fireEvent(input, touchEvent); + // jsdom has no soft keyboard/native touch-focus default action; mirror + // the browser focus that iOS only performs when touchstart is not canceled. + if (!touchEvent.defaultPrevented) { + input.focus(); + } + + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(input); + expect(screen.getByTestId("quick-chat-send")).toBeDisabled(); + + fireEvent.change(input, { target: { value: "Hello quick mobile" } }); + const sendButton = screen.getByTestId("quick-chat-send"); + fireEvent.touchStart(sendButton); + fireEvent.click(sendButton); + + await waitFor(() => { + expect(mockStreamChatResponse).toHaveBeenCalledTimes(1); + }); + expect(mockStreamChatResponse).toHaveBeenCalledWith("session-model", "Hello quick mobile", expect.any(Object), [], "proj-1"); + expect(await screen.findByTestId("quick-chat-stop")).toBeInTheDocument(); + expect(document.activeElement).toBe(input); + } finally { + isIOSSpy.mockRestore(); + } + }); + + it("FN-6301: Android mobile composer touchstart leaves native focus uncanceled", async () => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); + window.dispatchEvent(new Event("resize")); + mockUseViewportMode.mockReturnValue("mobile"); + const isIOSSpy = vi.spyOn(mobileScrollLock, "isIOS").mockReturnValue(false); + + try { + render(); + fireEvent.click(screen.getByTestId("quick-chat-fab")); + + const input = await screen.findByTestId("quick-chat-input") as HTMLTextAreaElement; + await waitFor(() => expect(input).not.toBeDisabled()); + input.blur(); + + const touchEvent = new TouchEvent("touchstart", { bubbles: true, cancelable: true }); + const preventDefaultSpy = vi.spyOn(touchEvent, "preventDefault"); + fireEvent(input, touchEvent); + if (!touchEvent.defaultPrevented) { + input.focus(); + } + + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(input); + } finally { + isIOSSpy.mockRestore(); + } + }); + it("uses icon-only model tag without pill styling when mobile header fallback is active", async () => { Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); window.dispatchEvent(new Event("resize"));