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
This commit is contained in:
5
.changeset/fn-6301-mobile-chat-composer.md
Normal file
5
.changeset/fn-6301-mobile-chat-composer.md
Normal file
@@ -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.
|
||||||
@@ -2880,8 +2880,9 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
if (window.innerWidth > 768) return;
|
if (window.innerWidth > 768) return;
|
||||||
if (!isIOS()) return;
|
if (!isIOS()) return;
|
||||||
if (document.activeElement === event.currentTarget) return;
|
if (document.activeElement === event.currentTarget) return;
|
||||||
event.preventDefault();
|
// FN-6301: do not preventDefault on the first unfocused iOS tap.
|
||||||
event.currentTarget.focus({ preventScroll: true });
|
// Native focus is the reliable path that raises the soft keyboard;
|
||||||
|
// the visualViewport/input-focus effects own scroll compensation.
|
||||||
}}
|
}}
|
||||||
rows={1}
|
rows={1}
|
||||||
data-testid="chat-input"
|
data-testid="chat-input"
|
||||||
@@ -3420,16 +3421,11 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
onTouchStart={(event) => {
|
onTouchStart={(event) => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
if (window.innerWidth > 768) 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 (!isIOS()) return;
|
||||||
if (document.activeElement === event.currentTarget) return;
|
if (document.activeElement === event.currentTarget) return;
|
||||||
event.preventDefault();
|
// FN-6301: do not preventDefault on the first unfocused iOS tap.
|
||||||
event.currentTarget.focus({ preventScroll: true });
|
// Native focus is the reliable path that raises the soft keyboard;
|
||||||
|
// the visualViewport/input-focus effects own scroll compensation.
|
||||||
}}
|
}}
|
||||||
rows={1}
|
rows={1}
|
||||||
data-testid="chat-input"
|
data-testid="chat-input"
|
||||||
|
|||||||
@@ -3027,26 +3027,14 @@ export function QuickChatFAB({
|
|||||||
onBlur={handleInputBlur}
|
onBlur={handleInputBlur}
|
||||||
onFocus={handleInputFocus}
|
onFocus={handleInputFocus}
|
||||||
onPaste={handlePaste}
|
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) => {
|
onTouchStart={(event) => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
if (window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) 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 (!isIOS()) return;
|
||||||
if (document.activeElement === event.currentTarget) return;
|
if (document.activeElement === event.currentTarget) return;
|
||||||
event.preventDefault();
|
// FN-6301: do not preventDefault on the first unfocused iOS tap.
|
||||||
event.currentTarget.focus({ preventScroll: true });
|
// Native focus is the reliable path that raises the soft keyboard;
|
||||||
|
// the visualViewport/input-focus effects own scroll compensation.
|
||||||
}}
|
}}
|
||||||
placeholder={inputPlaceholder}
|
placeholder={inputPlaceholder}
|
||||||
disabled={inputDisabled}
|
disabled={inputDisabled}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
|
|||||||
vi.mock("../../hooks/useChat");
|
vi.mock("../../hooks/useChat");
|
||||||
vi.mock("../../hooks/useMobileScrollLock", () => ({
|
vi.mock("../../hooks/useMobileScrollLock", () => ({
|
||||||
useMobileScrollLock: vi.fn(),
|
useMobileScrollLock: vi.fn(),
|
||||||
|
useMobileKeyboardViewportLock: vi.fn(),
|
||||||
isIOS: () => true,
|
isIOS: () => true,
|
||||||
_resetLockState: vi.fn(),
|
_resetLockState: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -605,20 +606,30 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
|||||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
|
||||||
|
|
||||||
const roomInput = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
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 () => {
|
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"));
|
await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct"));
|
||||||
|
|
||||||
const directInput = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
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 () => {
|
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();
|
mediaSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
|
|||||||
import { SWR_CACHE_KEYS, writeCache } from "../../utils/swrCache";
|
import { SWR_CACHE_KEYS, writeCache } from "../../utils/swrCache";
|
||||||
import * as useChatRoomsModule from "../../hooks/useChatRooms";
|
import * as useChatRoomsModule from "../../hooks/useChatRooms";
|
||||||
import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
|
import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
|
||||||
|
import * as mobileScrollLock from "../../hooks/useMobileScrollLock";
|
||||||
|
|
||||||
// Mock the hooks
|
// Mock the hooks
|
||||||
vi.mock("../../hooks/useChat");
|
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
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 () => {
|
it("mobile mode: send button sends on first touch and keeps composer focused", async () => {
|
||||||
const restoreMatchMedia = mockMobileViewport();
|
const restoreMatchMedia = mockMobileViewport();
|
||||||
const sendMessage = vi.fn();
|
const sendMessage = vi.fn();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useViewportMode } from "../../hooks/useViewportMode";
|
|||||||
import { useMobileKeyboard } from "../../hooks/useMobileKeyboard";
|
import { useMobileKeyboard } from "../../hooks/useMobileKeyboard";
|
||||||
import { useAppSettings } from "../../hooks/useAppSettings";
|
import { useAppSettings } from "../../hooks/useAppSettings";
|
||||||
import { useChatRooms } from "../../hooks/useChatRooms";
|
import { useChatRooms } from "../../hooks/useChatRooms";
|
||||||
|
import * as mobileScrollLock from "../../hooks/useMobileScrollLock";
|
||||||
import { QuickChatFAB } from "../QuickChatFAB";
|
import { QuickChatFAB } from "../QuickChatFAB";
|
||||||
import { FileBrowserProvider } from "../../context/FileBrowserContext";
|
import { FileBrowserProvider } from "../../context/FileBrowserContext";
|
||||||
|
|
||||||
@@ -799,6 +800,82 @@ describe("QuickChatFAB session-first UX", () => {
|
|||||||
expect(screen.getByTestId("quick-chat-session-option-session-model")).toBeInTheDocument();
|
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(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||||
|
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(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||||
|
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 () => {
|
it("uses icon-only model tag without pill styling when mobile header fallback is active", async () => {
|
||||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
|
||||||
window.dispatchEvent(new Event("resize"));
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
|||||||
Reference in New Issue
Block a user