From 7eab743eb2993b2139780721382673d39b1dc1d5 Mon Sep 17 00:00:00 2001 From: Fusion Date: Wed, 6 May 2026 21:54:58 -0700 Subject: [PATCH] fix(FN-3624): harden mobile chat keyboard layout behavior - Update ChatView keyboard spacer styling to keep viewport layout stable on mobile - Adjust ChatView viewport handling logic to better align chat body with keyboard transitions - Add ChatView regression tests covering mobile layout and keyboard interaction scenarios - Add useMobileKeyboard hook tests to lock keyboard metric behavior and prevent regressions Fusion-Task-Id: FN-3624 --- .../dashboard/app/components/ChatView.css | 2 +- .../dashboard/app/components/ChatView.tsx | 12 +++- .../components/__tests__/ChatView.test.tsx | 68 +++++++++++++++---- .../hooks/__tests__/useMobileKeyboard.test.ts | 46 +++++++++++++ 4 files changed, 111 insertions(+), 17 deletions(-) diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index f9d5aa7fe..e17d4b7cb 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -1103,7 +1103,7 @@ display: none; } - .chat-thread[style*="--keyboard-overlap"] { + .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)); /* useMobileKeyboard pins --vv-offset-top across pan-time scroll diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 0ed0b7b0b..7198f24bf 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -795,8 +795,13 @@ export function ChatView({ projectId, addToast }: ChatViewProps) { enabled: isMobile && !!activeSession, }); + // Only opt into visual-viewport sizing when we have concrete keyboard + // displacement (overlap or offset). The shared hook can report `keyboardOpen` + // during iOS settle/shrink phases with zero overlap; forcing vv-height in that + // transient state shrinks the thread and pushes the composer upward. + const hasKeyboardViewportDisplacement = keyboardOverlap > 0 || viewportOffsetTop > 0; const threadKeyboardStyle: CSSProperties = - keyboardOpen + keyboardOpen && hasKeyboardViewportDisplacement ? ({ "--keyboard-overlap": `${keyboardOverlap}px`, "--vv-offset-top": `${viewportOffsetTop}px`, @@ -1711,7 +1716,10 @@ export function ChatView({ projectId, addToast }: ChatViewProps) { )} {/* Thread */} -
+
{/* Header - always rendered in desktop/tablet, only rendered in mobile when viewing a thread */} {(hasThreadInView || !isMobile) && (
diff --git a/packages/dashboard/app/components/__tests__/ChatView.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.test.tsx index 0804b4231..c2556c7e6 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.test.tsx @@ -2741,7 +2741,7 @@ describe("ChatView mobile behavior", () => { } }); - it("mobile mode: applies --vv-height when keyboard opens with zero overlap (iOS last-resort signal)", async () => { + it("mobile mode: applies keyboard-active class for iOS fallback when viewport offset is present", async () => { const restoreMatchMedia = mockMobileViewport(); const { listeners, mockVV } = mockMobileVisualViewport({ innerHeight: 800, @@ -2758,11 +2758,8 @@ describe("ChatView mobile behavior", () => { const thread = document.querySelector(".chat-thread") as HTMLDivElement; expect(thread).toBeInTheDocument(); - expect(thread.style.getPropertyValue("--vv-height")).toBe(""); + expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(false); - // Focus the chat textarea so the hook treats the active element as a - // keyboard-focusable target — this is what unlocks the iOS last-resort - // signal where viewport shrinks but offsetTop+height closes the gap. const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; await act(async () => { textarea.focus(); @@ -2771,10 +2768,6 @@ describe("ChatView mobile behavior", () => { document.dispatchEvent(new Event("focusin")); }); - // iOS scenario: vv.height shrinks by 16px, vv.offsetTop also = 16. - // Both chromeOverlap (innerHeight - offsetTop - height) and the gap - // measurement are 0, but baselineHeight - vv.height = 16 trips the - // "viewport shrank" branch with overlap=0, keyboardOpen=true. Object.defineProperty(mockVV, "height", { value: 784, writable: true, configurable: true }); Object.defineProperty(mockVV, "offsetTop", { value: 16, writable: true, configurable: true }); @@ -2782,13 +2775,60 @@ describe("ChatView mobile behavior", () => { for (const cb of listeners.resize) cb(); }); - // The thread style is gated on keyboardOpen (not keyboardOverlap > 0), - // so --vv-height must still be applied even when the computed overlap - // collapses to zero. Regression guard for the gating change in - // ChatView.tsx:766. await waitFor(() => { expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("0px"); expect(thread.style.getPropertyValue("--vv-height")).toBe("784px"); + expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(true); + }); + } finally { + restoreMatchMedia.mockRestore(); + } + }); + + it("mobile mode: removes keyboard-active class immediately on blur even before visualViewport settles", async () => { + const restoreMatchMedia = mockMobileViewport(); + const { listeners, mockVV } = mockMobileVisualViewport({ + innerHeight: 800, + vvHeight: 800, + }); + + try { + setupMockChat({ + activeSession: activeSessionFixture, + messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }], + }); + + render(); + + const thread = document.querySelector(".chat-thread") as HTMLDivElement; + expect(thread).toBeInTheDocument(); + + const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; + await act(async () => { + textarea.focus(); + }); + act(() => { + document.dispatchEvent(new Event("focusin")); + }); + + Object.defineProperty(mockVV, "height", { value: 560, writable: true, configurable: true }); + Object.defineProperty(window, "innerHeight", { value: 560, writable: true, configurable: true }); + + act(() => { + for (const cb of listeners.resize) cb(); + }); + + await waitFor(() => { + expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(true); + }); + + textarea.blur(); + act(() => { + document.dispatchEvent(new Event("focusout")); + }); + + await waitFor(() => { + expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(false); }); } finally { restoreMatchMedia.mockRestore(); @@ -3096,7 +3136,7 @@ describe("ChatView mobile CSS contract", () => { }); it("mobile includes keyboard-aware chat-thread height rule", () => { - expect(css).toMatch(/\.chat-thread\[style\*=\"--keyboard-overlap\"\]\s*\{[^}]*--vv-height/); + expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread--keyboard-active\s*\{[^}]*--vv-height/); }); it("mobile widens chat bubbles for readability", () => { diff --git a/packages/dashboard/app/hooks/__tests__/useMobileKeyboard.test.ts b/packages/dashboard/app/hooks/__tests__/useMobileKeyboard.test.ts index 4b8ce9607..c2afa9477 100644 --- a/packages/dashboard/app/hooks/__tests__/useMobileKeyboard.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useMobileKeyboard.test.ts @@ -361,6 +361,52 @@ describe("useMobileKeyboard", () => { input.remove(); }); + it("uses iOS gap fallback when viewport shrink occurs with offsetTop at 0", async () => { + const { listeners, mockVV } = setupMobileVisualViewport({ + innerHeight: 844, + vvHeight: 844, + }); + + const input = document.createElement("input"); + input.type = "text"; + document.body.appendChild(input); + + const { result } = renderHook(() => useMobileKeyboard()); + + await waitFor(() => { + expect(result.current.keyboardOpen).toBe(false); + }); + + input.focus(); + Object.defineProperty(mockVV, "height", { + value: 824, + writable: true, + configurable: true, + }); + Object.defineProperty(mockVV, "offsetTop", { + value: 0, + writable: true, + configurable: true, + }); + Object.defineProperty(window, "innerHeight", { + value: 824, + writable: true, + configurable: true, + }); + + act(() => { + for (const cb of listeners.resize) cb(); + }); + + await waitFor(() => { + expect(result.current.keyboardOverlap).toBe(20); + expect(result.current.viewportOffsetTop).toBe(0); + expect(result.current.keyboardOpen).toBe(true); + }); + + input.remove(); + }); + it("reports keyboardOpen=false the instant focus leaves an input even while visualViewport still reports keyboard-up size", async () => { // Regression for the ChatView "composer crawls down with the keyboard" // bug: on iOS the visualViewport keeps reporting the small mid-dismiss