From 602fefff19d9888ff4bb2821964dd1229755206e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 16 Jun 2026 21:25:19 -0700 Subject: [PATCH] FN-6498: smooth mobile quick chat viewport tracking Keep the mobile Quick Chat sheet aligned with visualViewport changes without redundant layout writes. - Dedupe repeated mobile visualViewport samples before updating sheet CSS variables. - Reset viewport tracking state and CSS variables on close/unmount to prevent stale reopen sizing. - Add regression coverage for mobile tracking, reopen behavior, desktop bypass, and missing visualViewport fallback. - Document the viewport-smoothing pitfall alongside the keyboard board-shift solution. Files changed: .../quick-chat-mobile-keyboard-board-shift.md | 4 + packages/dashboard/app/components/QuickChatFAB.tsx | 25 +++- .../app/components/__tests__/QuickChatFAB.test.tsx | 141 +++++++++++++++++++++ 3 files changed, 167 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-6498 Fusion-Task-Lineage: 23c17843-778a-4337-aeed-c80793615738 --- .../quick-chat-mobile-keyboard-board-shift.md | 4 + .../dashboard/app/components/QuickChatFAB.tsx | 25 +++- .../__tests__/QuickChatFAB.test.tsx | 141 ++++++++++++++++++ 3 files changed, 167 insertions(+), 3 deletions(-) diff --git a/docs/solutions/ui-bugs/quick-chat-mobile-keyboard-board-shift.md b/docs/solutions/ui-bugs/quick-chat-mobile-keyboard-board-shift.md index ed4531f5f0..eee5d5e462 100644 --- a/docs/solutions/ui-bugs/quick-chat-mobile-keyboard-board-shift.md +++ b/docs/solutions/ui-bugs/quick-chat-mobile-keyboard-board-shift.md @@ -45,6 +45,10 @@ Model fullscreen mobile overlays as explicit board-layout suppressors in `comput This keeps the board's footer/mobile-nav padding classes present for the entire time Quick Chat is open. The board therefore never shifts in response to the Quick Chat keyboard, leaving nothing to snap back after the overlay closes. +## Related viewport-smoothing pitfall + +FN-6498 found a separate Quick Chat viewport-tracking jank source inside `QuickChatFAB.tsx`: mobile `visualViewport` `resize` and `scroll` events can report the same `{ height, offsetTop }` sample during one keyboard animation tick, especially on Android Chrome with `interactive-widget=resizes-content`. The sheet should still own `--vv-height` / `--vv-offset-top`, but same-sample writes are deduped so the overlay does not add redundant style/layout invalidation while the board-shift suppression described above keeps the board underneath stable. + ## Regression coverage Cover the invariant at the pure helper seam: diff --git a/packages/dashboard/app/components/QuickChatFAB.tsx b/packages/dashboard/app/components/QuickChatFAB.tsx index 23f09e6944..0f3a944835 100644 --- a/packages/dashboard/app/components/QuickChatFAB.tsx +++ b/packages/dashboard/app/components/QuickChatFAB.tsx @@ -1180,27 +1180,46 @@ export function QuickChatFAB({ // its own keyboard animation; deferring our write to the next frame // makes the panel lag iOS by one paint, which is visible as a slide. // Synchronous writes keep the panel locked to the visual viewport. + /* + FNXC:QuickChatMobileResize 2026-06-16-18:14: + FN-6498 requires the mobile fullscreen sheet to track visualViewport samples smoothly across iOS and Android. Keep iOS second-focus offsetTop compensation and keyboard-dismiss pre-grow, but avoid redundant same-sample resize/scroll writes that add layout thrash on Android Chrome interactive-widget=resizes-content. + */ useLayoutEffect(() => { if (!isOpen) return; + if (!isMobile) return; if (typeof window === "undefined" || !window.visualViewport) return; + if (window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return; const panel = panelRef.current; if (!panel) return; const vv = window.visualViewport; + let lastAppliedSample: { height: number; offsetTop: number } | null = null; const apply = () => { if (suppressVvShrinkRef.current) return; - panel.style.setProperty("--vv-height", `${vv.height}px`); - panel.style.setProperty("--vv-offset-top", `${vv.offsetTop || 0}px`); + const nextSample = { height: vv.height, offsetTop: vv.offsetTop || 0 }; + if ( + lastAppliedSample + && lastAppliedSample.height === nextSample.height + && lastAppliedSample.offsetTop === nextSample.offsetTop + ) { + return; + } + lastAppliedSample = nextSample; + panel.style.setProperty("--vv-height", `${nextSample.height}px`); + panel.style.setProperty("--vv-offset-top", `${nextSample.offsetTop}px`); }; apply(); vv.addEventListener("resize", apply); vv.addEventListener("scroll", apply); return () => { + suppressVvShrinkRef.current = false; vv.removeEventListener("resize", apply); vv.removeEventListener("scroll", apply); + panel.style.removeProperty("--vv-height"); + panel.style.removeProperty("--vv-offset-top"); }; - }, [isOpen]); + }, [isMobile, isOpen]); const resolvedModelSelection = selectedModel || configuredDefaultModelSelection; const targetModelSelection = useMemo( diff --git a/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx b/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx index 8e45ca2663..9686786578 100644 --- a/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx @@ -122,6 +122,35 @@ function createDeferredPromise() { return { promise, resolve, reject }; } +function mockQuickChatVisualViewport({ height = 800, offsetTop = 0, width = 390 } = {}) { + const visualViewport = new EventTarget() as VisualViewport; + Object.defineProperties(visualViewport, { + height: { value: height, writable: true, configurable: true }, + width: { value: width, writable: true, configurable: true }, + offsetTop: { value: offsetTop, writable: true, configurable: true }, + offsetLeft: { value: 0, writable: true, configurable: true }, + pageTop: { value: 0, writable: true, configurable: true }, + pageLeft: { value: 0, writable: true, configurable: true }, + scale: { value: 1, writable: true, configurable: true }, + }); + Object.defineProperty(window, "visualViewport", { value: visualViewport, configurable: true, writable: true }); + return visualViewport; +} + +async function driveQuickChatVisualViewport( + visualViewport: VisualViewport, + { height, offsetTop, eventType = "resize" }: { height: number; offsetTop: number; eventType?: "resize" | "scroll" }, +) { + Object.defineProperties(visualViewport, { + height: { value: height, writable: true, configurable: true }, + offsetTop: { value: offsetTop, writable: true, configurable: true }, + }); + + await act(async () => { + visualViewport.dispatchEvent(new Event(eventType)); + }); +} + describe("QuickChatFAB session-first UX", () => { beforeEach(() => { vi.clearAllMocks(); @@ -976,6 +1005,118 @@ describe("QuickChatFAB session-first UX", () => { } }); + it("FN-6498: mobile visualViewport tracking skips duplicate resize/scroll writes and clears stale variables", async () => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); + window.dispatchEvent(new Event("resize")); + mockUseViewportMode.mockReturnValue("mobile"); + mockUseMobileKeyboard.mockReturnValue({ + keyboardOverlap: 280, + viewportHeight: 520, + viewportOffsetTop: 0, + keyboardOpen: true, + }); + const visualViewport = mockQuickChatVisualViewport({ height: 800, offsetTop: 0 }); + const styleWriteSpy = vi.spyOn(CSSStyleDeclaration.prototype, "setProperty"); + const styleRemoveSpy = vi.spyOn(CSSStyleDeclaration.prototype, "removeProperty"); + + const rendered = render(); + fireEvent.click(screen.getByTestId("quick-chat-fab")); + const panel = await screen.findByTestId("quick-chat-panel"); + await screen.findByTestId("quick-chat-input"); + + expect(panel.style.getPropertyValue("--vv-height")).toBe("800px"); + expect(panel.style.getPropertyValue("--vv-offset-top")).toBe("0px"); + const initialWriteCount = styleWriteSpy.mock.calls.length; + + await driveQuickChatVisualViewport(visualViewport, { height: 520, offsetTop: 0, eventType: "resize" }); + expect(panel.style.getPropertyValue("--vv-height")).toBe("520px"); + expect(panel.style.getPropertyValue("--vv-offset-top")).toBe("0px"); + const writesAfterResize = styleWriteSpy.mock.calls.length; + expect(writesAfterResize - initialWriteCount).toBe(2); + + await driveQuickChatVisualViewport(visualViewport, { height: 520, offsetTop: 0, eventType: "scroll" }); + expect(styleWriteSpy.mock.calls.length).toBe(writesAfterResize); + + await driveQuickChatVisualViewport(visualViewport, { height: 360, offsetTop: 24, eventType: "resize" }); + expect(panel.style.getPropertyValue("--vv-height")).toBe("360px"); + expect(panel.style.getPropertyValue("--vv-offset-top")).toBe("24px"); + + await driveQuickChatVisualViewport(visualViewport, { height: 800, offsetTop: 0, eventType: "resize" }); + expect(panel.style.getPropertyValue("--vv-height")).toBe("800px"); + expect(panel.style.getPropertyValue("--vv-offset-top")).toBe("0px"); + + rendered.unmount(); + expect(panel.style.getPropertyValue("--vv-height")).toBe(""); + expect(panel.style.getPropertyValue("--vv-offset-top")).toBe(""); + expect(styleRemoveSpy).toHaveBeenCalledWith("--vv-height"); + expect(styleRemoveSpy).toHaveBeenCalledWith("--vv-offset-top"); + + styleWriteSpy.mockRestore(); + styleRemoveSpy.mockRestore(); + }); + + it("FN-6498: close while suppressing dismiss samples resets tracking for reopen", async () => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); + window.dispatchEvent(new Event("resize")); + mockUseViewportMode.mockReturnValue("mobile"); + const visualViewport = mockQuickChatVisualViewport({ height: 800, offsetTop: 0 }); + + render(); + fireEvent.click(screen.getByTestId("quick-chat-fab")); + const input = await screen.findByTestId("quick-chat-input") as HTMLTextAreaElement; + const firstPanel = await screen.findByTestId("quick-chat-panel"); + + await driveQuickChatVisualViewport(visualViewport, { height: 360, offsetTop: 24, eventType: "resize" }); + expect(firstPanel.style.getPropertyValue("--vv-height")).toBe("360px"); + expect(firstPanel.style.getPropertyValue("--vv-offset-top")).toBe("24px"); + + fireEvent.blur(input); + expect(firstPanel.style.getPropertyValue("--vv-height")).toBe(""); + expect(firstPanel.style.getPropertyValue("--vv-offset-top")).toBe(""); + fireEvent.click(screen.getByTestId("quick-chat-close")); + expect(screen.queryByTestId("quick-chat-panel")).toBeNull(); + + await driveQuickChatVisualViewport(visualViewport, { height: 800, offsetTop: 0, eventType: "resize" }); + fireEvent.click(screen.getByTestId("quick-chat-fab")); + const reopenedPanel = await screen.findByTestId("quick-chat-panel"); + expect(reopenedPanel.style.getPropertyValue("--vv-height")).toBe("800px"); + expect(reopenedPanel.style.getPropertyValue("--vv-offset-top")).toBe("0px"); + + await driveQuickChatVisualViewport(visualViewport, { height: 520, offsetTop: 0, eventType: "resize" }); + expect(reopenedPanel.style.getPropertyValue("--vv-height")).toBe("520px"); + expect(reopenedPanel.style.getPropertyValue("--vv-offset-top")).toBe("0px"); + }); + + it("FN-6498: desktop quick chat does not attach visualViewport tracking listeners", async () => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 }); + window.dispatchEvent(new Event("resize")); + mockUseViewportMode.mockReturnValue("desktop"); + const visualViewport = mockQuickChatVisualViewport({ height: 800, offsetTop: 0, width: 1024 }); + const addListenerSpy = vi.spyOn(visualViewport, "addEventListener"); + + render(); + fireEvent.click(screen.getByTestId("quick-chat-fab")); + + expect(await screen.findByTestId("quick-chat-panel")).toBeInTheDocument(); + expect(screen.getByTestId("quick-chat-resize-n")).toBeInTheDocument(); + expect(addListenerSpy).not.toHaveBeenCalledWith("resize", expect.any(Function)); + expect(addListenerSpy).not.toHaveBeenCalledWith("scroll", expect.any(Function)); + }); + + it("FN-6498: missing visualViewport leaves mobile panel on CSS fallback without listeners", async () => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); + Object.defineProperty(window, "visualViewport", { value: undefined, configurable: true, writable: true }); + window.dispatchEvent(new Event("resize")); + mockUseViewportMode.mockReturnValue("mobile"); + + render(); + fireEvent.click(screen.getByTestId("quick-chat-fab")); + + const panel = await screen.findByTestId("quick-chat-panel"); + expect(panel.style.getPropertyValue("--vv-height")).toBe(""); + expect(panel.style.getPropertyValue("--vv-offset-top")).toBe(""); + }); + 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"));