From a2d6349bb87769630e9abf0a7f929629e1e273c2 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 4 Jul 2026 10:27:57 -0700 Subject: [PATCH] FN-7504: keep mobile chat composer above keyboard chrome Keeps the mobile Chat composer visible when iOS keyboard accessory chrome is open. - Add ChatView-local keyboard accessory clearance for iOS keyboard-active composer padding. - Reset the clearance when keyboard tracking is suppressed or torn down, without adding persistent thread transforms. - Cover empty, streaming, room, and Android mobile composer behavior with regression tests. - Add a patch changeset for the published Fusion package. Files changed: .../fn-7504-mobile-chat-composer-keyboard.md | 7 + packages/dashboard/app/components/ChatView.css | 4 + packages/dashboard/app/components/ChatView.tsx | 10 ++ .../components/__tests__/ChatView.mobile.test.tsx | 191 +++++++++++++++++++++ 4 files changed, 212 insertions(+) Fusion-Task-Id: FN-7504 Fusion-Task-Lineage: 4e7ed36e-e7d5-4cd1-934b-3b0f19ed6615 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7504-mobile-chat-composer-keyboard.md | 7 + .../dashboard/app/components/ChatView.css | 4 + .../dashboard/app/components/ChatView.tsx | 10 + .../__tests__/ChatView.mobile.test.tsx | 191 ++++++++++++++++++ 4 files changed, 212 insertions(+) create mode 100644 .changeset/fn-7504-mobile-chat-composer-keyboard.md diff --git a/.changeset/fn-7504-mobile-chat-composer-keyboard.md b/.changeset/fn-7504-mobile-chat-composer-keyboard.md new file mode 100644 index 0000000000..2bb827bed9 --- /dev/null +++ b/.changeset/fn-7504-mobile-chat-composer-keyboard.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix mobile Chat composer being hidden behind the keyboard accessory bar. +category: fix +dev: Adds keyboard-open bottom clearance in ChatView so the composer clears the iOS input-assistant/autofill bar without a persistent .chat-thread transform or Android reserved-gap. diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index f41cf37961..7e55c8fe74 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -2175,6 +2175,10 @@ Queued-message banners stack above the composer input with a capped scroll area, ancestor stays `transform: none` and the keyboard stays up. */ } + .chat-thread--keyboard-active .chat-input-area { + padding-bottom: calc(var(--space-md) + env(safe-area-inset-bottom, 0px) + var(--chat-keyboard-accessory-clearance, 0px)); + } + /* On mobile, the active scope affordance uses a full-width pinned footer. */ .chat-sidebar-footer { display: block; diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index e6af84f660..8e66988323 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -1145,6 +1145,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout const apply = () => { if (suppressVvShrinkRef.current) { thread.classList.remove("chat-thread--keyboard-active"); + thread.style.setProperty("--chat-keyboard-accessory-clearance", "0px"); thread.style.transform = ""; thread.style.willChange = ""; return; @@ -1157,6 +1158,14 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout const keyboardActive = (overlap > 0 || offsetTop > 0) && isKeyboardTrackingFocusable(document.activeElement); thread.classList.toggle("chat-thread--keyboard-active", keyboardActive); + /* + FNXC:ChatComposer 2026-07-04-09:42: + Mobile Chat's composer must stay fully visible above the soft keyboard and the iOS input-assistant/autofill bar, which Safari does not subtract from visualViewport.height. Keep the clearance ChatView-local and keyed to iOS keyboard-active state so the shared keyboard hook contract stays stable, .chat-thread does not gain a persistent transform (anti-blur invariant), and Android resizes-content does not regain an empty reserved gap. + */ + thread.style.setProperty( + "--chat-keyboard-accessory-clearance", + keyboardActive && isIOS() ? "calc(var(--space-2xl) + var(--space-md))" : "0px", + ); // Drift compensation is applied here (not in CSS) so .chat-thread — // an ancestor of the focused composer textarea — only gets a @@ -1190,6 +1199,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout window.removeEventListener("pageshow", apply); document.removeEventListener("visibilitychange", apply); thread.classList.remove("chat-thread--keyboard-active"); + thread.style.setProperty("--chat-keyboard-accessory-clearance", "0px"); thread.style.transform = ""; thread.style.willChange = ""; }; diff --git a/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx index 44c0cbf503..a2eea7ba96 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx @@ -208,6 +208,34 @@ describe("ChatView mobile behavior", () => { })); } + async function focusComposerAndOpenKeyboard({ + listeners, + mockVV, + vvHeight, + offsetTop = 0, + }: { + listeners: Record void>>; + mockVV: VisualViewport; + vvHeight: number; + offsetTop?: number; + }) { + const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; + await act(async () => { + textarea.focus(); + }); + act(() => { + document.dispatchEvent(new Event("focusin")); + }); + + Object.defineProperty(mockVV, "offsetTop", { value: offsetTop, writable: true, configurable: true }); + Object.defineProperty(mockVV, "height", { value: vvHeight, writable: true, configurable: true }); + act(() => { + for (const cb of listeners.resize) cb(); + }); + + return textarea; + } + it("mobile mode: does not render thread header when no active session (list view)", async () => { const restoreMatchMedia = mockMobileViewport(); try { @@ -842,6 +870,164 @@ describe("ChatView mobile behavior", () => { } }); + it("mobile mode: keeps direct empty composer above iOS keyboard accessory chrome without a transform", async () => { + const restoreMatchMedia = mockMobileViewport(); + const isIOSSpy = vi.spyOn(mobileScrollLock, "isIOS").mockReturnValue(true); + const { listeners, mockVV } = mockMobileVisualViewport({ + innerHeight: 844, + vvHeight: 844, + }); + + try { + setupMockChat({ + activeSession: activeSessionFixture, + messages: [], + }); + + await renderWithAct(); + + const thread = document.querySelector(".chat-thread") as HTMLDivElement; + const inputArea = document.querySelector(".chat-input-area") as HTMLDivElement; + const inputRow = document.querySelector(".chat-input-row") as HTMLDivElement; + expect(thread).toBeInTheDocument(); + expect(inputArea).toBeInTheDocument(); + expect(inputRow).toBeInTheDocument(); + expect(thread.style.getPropertyValue("--chat-keyboard-accessory-clearance")).toBe("0px"); + + const textarea = await focusComposerAndOpenKeyboard({ listeners, mockVV, vvHeight: 560 }); + + await waitFor(() => { + expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(true); + expect(thread.style.getPropertyValue("--vv-height")).toBe("560px"); + expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("284px"); + expect(thread.style.getPropertyValue("--chat-keyboard-accessory-clearance")).toBe("calc(var(--space-2xl) + var(--space-md))"); + expect(thread.style.transform).toBe(""); + expect(thread.style.willChange).toBe(""); + }); + + await act(async () => { + textarea.blur(); + document.dispatchEvent(new Event("focusout")); + }); + + await waitFor(() => { + expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(false); + expect(thread.style.getPropertyValue("--chat-keyboard-accessory-clearance")).toBe("0px"); + }); + } finally { + isIOSSpy.mockRestore(); + restoreMatchMedia.mockRestore(); + } + }); + + it("mobile mode: keeps populated streaming direct composer above iOS accessory chrome", async () => { + const restoreMatchMedia = mockMobileViewport(); + const isIOSSpy = vi.spyOn(mobileScrollLock, "isIOS").mockReturnValue(true); + const { listeners, mockVV } = mockMobileVisualViewport({ + innerHeight: 844, + vvHeight: 844, + }); + + try { + setupMockChat({ + activeSession: activeSessionFixture, + messages: [ + { id: "msg-001", sessionId: "session-001", role: "assistant", content: "Loaded history", createdAt: "2026-04-08T00:00:00.000Z" }, + ], + isStreaming: true, + streamingText: "Streaming response", + }); + + await renderWithAct(); + + const thread = document.querySelector(".chat-thread") as HTMLDivElement; + await focusComposerAndOpenKeyboard({ listeners, mockVV, vvHeight: 560 }); + + await waitFor(() => { + expect(screen.getByText("Loaded history")).toBeInTheDocument(); + expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(true); + expect(thread.style.getPropertyValue("--chat-keyboard-accessory-clearance")).toBe("calc(var(--space-2xl) + var(--space-md))"); + }); + } finally { + isIOSSpy.mockRestore(); + restoreMatchMedia.mockRestore(); + } + }); + + it("mobile mode: keeps room composer above iOS keyboard accessory chrome", async () => { + const restoreMatchMedia = mockMobileViewport(); + const isIOSSpy = vi.spyOn(mobileScrollLock, "isIOS").mockReturnValue(true); + const { listeners, mockVV } = mockMobileVisualViewport({ + innerHeight: 844, + vvHeight: 844, + }); + + localStorage.setItem("fusion:chat-scope", "rooms"); + try { + const room = createRoomFixture("general"); + setupMockChat({ activeSession: null, messages: [] }); + setupMockRooms({ + activeRoom: room, + rooms: [room], + messages: [ + { + id: "room-msg-001", + roomId: room.id, + role: "assistant", + content: "Room history", + thinkingOutput: null, + toolCalls: [], + createdAt: "2026-05-12T00:00:00.000Z", + }, + ], + }); + + await renderWithAct(); + + const thread = document.querySelector(".chat-thread") as HTMLDivElement; + await focusComposerAndOpenKeyboard({ listeners, mockVV, vvHeight: 560 }); + + await waitFor(() => { + expect(screen.getByText("Room history")).toBeInTheDocument(); + expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(true); + expect(thread.style.getPropertyValue("--chat-keyboard-accessory-clearance")).toBe("calc(var(--space-2xl) + var(--space-md))"); + }); + } finally { + localStorage.removeItem("fusion:chat-scope"); + isIOSSpy.mockRestore(); + restoreMatchMedia.mockRestore(); + } + }); + + it("mobile mode: does not reserve iOS accessory clearance on Android resizes-content keyboard samples", async () => { + const restoreMatchMedia = mockMobileViewport(); + const isIOSSpy = vi.spyOn(mobileScrollLock, "isIOS").mockReturnValue(false); + const { listeners, mockVV } = mockMobileVisualViewport({ + innerHeight: 844, + vvHeight: 844, + }); + + try { + setupMockChat({ + activeSession: activeSessionFixture, + messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }], + }); + + await renderWithAct(); + + const thread = document.querySelector(".chat-thread") as HTMLDivElement; + await focusComposerAndOpenKeyboard({ listeners, mockVV, vvHeight: 560 }); + + await waitFor(() => { + expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(true); + expect(thread.style.getPropertyValue("--chat-keyboard-accessory-clearance")).toBe("0px"); + }); + } finally { + isIOSSpy.mockRestore(); + restoreMatchMedia.mockRestore(); + } + }); + it("mobile mode: applies keyboard-active class for iOS fallback when viewport offset is present", async () => { const restoreMatchMedia = mockMobileViewport(); const { listeners, mockVV } = mockMobileVisualViewport({ @@ -2011,6 +2197,11 @@ describe("ChatView mobile CSS contract", () => { expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread--keyboard-active\s*\{[^}]*--vv-height/); }); + it("mobile adds tokenized keyboard accessory clearance only to active composer padding", async () => { + expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread--keyboard-active\s+\.chat-input-area\s*\{[^}]*padding-bottom:\s*calc\(var\(--space-md\) \+ env\(safe-area-inset-bottom, 0px\) \+ var\(--chat-keyboard-accessory-clearance, 0px\)\)/); + expect(css).toMatch(/\.chat-input-area\s*\{[^}]*padding:\s*var\(--space-md\) var\(--space-lg\)[^}]*\}/); + }); + it("mobile makes chat bubbles full-width for narrow-column readability", async () => { expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-message\s*\{[^}]*max-width:\s*100%/); });