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"));