From 2e3b9659094bc9d726a2fb217d3831bbbd24f6b1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 20 Jun 2026 00:41:36 -0700 Subject: [PATCH] FN-6757: smooth mobile Quick Chat keyboard resizing Smooth Quick Chat mobile panel height changes while keeping viewport sampling synchronous.\n\n- Add Android resize-content detection that toggles height easing only for coarse keyboard viewport samples.\n- Clear smoothing and viewport CSS variables on blur, unmount, and suppressed shrink paths.\n- Cover Android smoothing, iOS offsetTop refocus, Latest gate, and streaming state with Quick Chat regression tests.\n- Document the mobile keyboard smoothing behavior and add a published package changeset.\n\nFiles changed:\n .changeset/smooth-mobile-quick-chat-keyboard.md | 5 +\n docs/dashboard-guide.md | 1 +\n .../quick-chat-mobile-keyboard-board-shift.md | 2 +\n packages/dashboard/app/components/QuickChatFAB.css | 8 ++\n packages/dashboard/app/components/QuickChatFAB.tsx | 17 +++\n .../app/components/__tests__/QuickChatFAB.test.tsx | 141 +++++++++++++++++++++\n 6 files changed, 174 insertions(+) Fusion-Task-Id: FN-6757 Fusion-Task-Lineage: d301aee8-ef76-4d70-a4cd-91641bb05259 --- .../smooth-mobile-quick-chat-keyboard.md | 5 + docs/dashboard-guide.md | 1 + .../quick-chat-mobile-keyboard-board-shift.md | 2 + .../dashboard/app/components/QuickChatFAB.css | 8 + .../dashboard/app/components/QuickChatFAB.tsx | 17 +++ .../__tests__/QuickChatFAB.test.tsx | 141 ++++++++++++++++++ 6 files changed, 174 insertions(+) create mode 100644 .changeset/smooth-mobile-quick-chat-keyboard.md diff --git a/.changeset/smooth-mobile-quick-chat-keyboard.md b/.changeset/smooth-mobile-quick-chat-keyboard.md new file mode 100644 index 0000000000..efaa19df61 --- /dev/null +++ b/.changeset/smooth-mobile-quick-chat-keyboard.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Smooth the mobile Quick Chat fullscreen sheet during Android soft-keyboard viewport resizing while preserving synchronous iOS visualViewport alignment. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index ccc9cd9bd6..cabcf5e385 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -320,6 +320,7 @@ Quick Chat is an optional floating panel for fast, project-scoped assistant conv - Quick Chat now mirrors full Chat tail behavior: if you scroll up, live updates stop auto-following and a **Latest** jump control appears until you jump back down. - On mobile, Quick Chat re-anchors to the newest message whenever the panel is opened/reopened and when page visibility is restored, while still preserving the near-bottom gate so intentional scroll-away keeps **Latest** jump behavior. - On mobile, Quick Chat bubbles are slightly wider while keeping compact tool-call summary layout and full-screen/safe-area behavior intact. +- On mobile, the fullscreen Quick Chat sheet follows soft-keyboard `visualViewport` height/offset samples directly, dedupes repeated identical samples, and smooths Android resize-content height-only changes so the sheet does not visibly step while the keyboard animates. iOS-style non-zero `offsetTop` samples still apply synchronously so transform alignment stays exact. - On mobile, Quick Chat send reliability includes a delivery watchdog: if a queued message would otherwise stay stranded in the composer after a dropped or suspended stream, it is re-confirmed and delivered once no generation is in flight and no live stream is connected, so sends are not silently dropped. - On mobile, Quick Chat sends exactly once per tap even when the browser emits paired pointer and touch events; a stop tap immediately after send is still honored. - While a response is streaming, the Quick Chat stop control matches the send button's square dimensions (including on mobile) instead of collapsing toward its icon, so it stays an easy touch target. 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 eee5d5e462..5995900bd6 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 @@ -49,6 +49,8 @@ This keeps the board's footer/mobile-nav padding classes present for the entire 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. +FN-6757 extends that mitigation for Android Chrome's distinct resize-content samples. When `visualViewport.height` shrinks below the stable layout viewport and `offsetTop` stays `0`, Quick Chat adds a height-only smoothing class so the fullscreen sheet eases through the keyboard animation instead of visibly stepping through every browser sample. The actual `--vv-height` writes remain synchronous and deduped, so the final sample is authoritative and FN-6503's Android `focusin` tail can still converge on late keyboard geometry. Non-zero `offsetTop` samples (the iOS keyboard path) do not enable smoothing because the sheet's translateY alignment must track the visual viewport immediately. + ## Regression coverage Cover the invariant at the pure helper seam: diff --git a/packages/dashboard/app/components/QuickChatFAB.css b/packages/dashboard/app/components/QuickChatFAB.css index b7c5ed96db..4ff06ea8dd 100644 --- a/packages/dashboard/app/components/QuickChatFAB.css +++ b/packages/dashboard/app/components/QuickChatFAB.css @@ -962,6 +962,14 @@ Quick chat session rows include a separate rename button so selecting a session, z-index: 1100; } + .quick-chat-panel.quick-chat-panel--vv-height-smoothing { + /* + FNXC:QuickChatMobileResize 2026-06-19-23:57: + Android Chrome interactive-widget=resizes-content can emit coarse visualViewport heights while the keyboard animates. Ease only the height-bound properties after that Android-shaped constant-innerHeight shrink is detected; transform stays synchronous so iOS offsetTop compensation remains locked to Safari's keyboard frame. + */ + transition: height var(--transition-fast), max-height var(--transition-fast); + } + .quick-chat-session-dropdown { left: 0; right: 0; diff --git a/packages/dashboard/app/components/QuickChatFAB.tsx b/packages/dashboard/app/components/QuickChatFAB.tsx index a8144de985..65df3a8ea7 100644 --- a/packages/dashboard/app/components/QuickChatFAB.tsx +++ b/packages/dashboard/app/components/QuickChatFAB.tsx @@ -1214,6 +1214,9 @@ export function QuickChatFAB({ FNXC:QuickChatMobileResize 2026-06-16-23:45: FN-6503 requires the first Android open to re-sample visualViewport after the stealth-input to composer focus handoff. Android Chrome can settle the keyboard shrink without a later resize observed by this panel effect, so focusin runs an immediate synchronous apply plus a short settle tail while resize/scroll remain synchronous for iOS animation lock-step. + + FNXC:QuickChatMobileResize 2026-06-19-23:57: + FN-6757 keeps distinct visualViewport samples synchronous, but marks Android Chrome's constant-layout-viewport keyboard path for CSS easing. iOS Safari shrinks window.innerHeight with vv.height or reports a non-zero offsetTop on re-focus, so it stays off the smoothing class and avoids the one-paint lag caused by rAF throttling. */ useLayoutEffect(() => { if (!isOpen) return; @@ -1225,6 +1228,17 @@ export function QuickChatFAB({ const vv = window.visualViewport; let lastAppliedSample: { height: number; offsetTop: number } | null = null; + let androidViewportSmoothingObserved = false; + const updateAndroidViewportSmoothing = (nextSample: { height: number; offsetTop: number }) => { + const layoutViewportShrink = window.innerHeight - nextSample.height; + const isAndroidResizeContentSample = nextSample.offsetTop === 0 && layoutViewportShrink > 1; + if (nextSample.offsetTop !== 0) { + androidViewportSmoothingObserved = false; + } else if (isAndroidResizeContentSample) { + androidViewportSmoothingObserved = true; + } + panel.classList.toggle("quick-chat-panel--vv-height-smoothing", androidViewportSmoothingObserved); + }; const apply = () => { if (suppressVvShrinkRef.current) return; const nextSample = { height: vv.height, offsetTop: vv.offsetTop || 0 }; @@ -1236,6 +1250,7 @@ export function QuickChatFAB({ return; } lastAppliedSample = nextSample; + updateAndroidViewportSmoothing(nextSample); panel.style.setProperty("--vv-height", `${nextSample.height}px`); panel.style.setProperty("--vv-offset-top", `${nextSample.offsetTop}px`); }; @@ -1307,6 +1322,7 @@ export function QuickChatFAB({ window.clearTimeout(timeoutId); } cancelTailPoll(); + panel.classList.remove("quick-chat-panel--vv-height-smoothing"); panel.style.removeProperty("--vv-height"); panel.style.removeProperty("--vv-offset-top"); }; @@ -2533,6 +2549,7 @@ export function QuickChatFAB({ && panelRef.current ) { suppressVvShrinkRef.current = true; + panelRef.current.classList.remove("quick-chat-panel--vv-height-smoothing"); panelRef.current.style.removeProperty("--vv-height"); panelRef.current.style.removeProperty("--vv-offset-top"); window.setTimeout(() => { diff --git a/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx b/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx index 46a9394208..fd065a06e6 100644 --- a/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx @@ -1313,6 +1313,147 @@ describe("QuickChatFAB session-first UX", () => { styleRemoveSpy.mockRestore(); }); + it("FN-6757: eases Android resize-content viewport samples without extra writes or stale variables", async () => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 800 }); + 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 rendered = render(); + fireEvent.click(screen.getByTestId("quick-chat-fab")); + const panel = await screen.findByTestId("quick-chat-panel"); + const input = await screen.findByTestId("quick-chat-input"); + + expect(panel.style.getPropertyValue("--vv-height")).toBe("800px"); + expect(panel).not.toHaveClass("quick-chat-panel--vv-height-smoothing"); + + await driveQuickChatVisualViewport(visualViewport, { height: 760, offsetTop: 0, eventType: "resize" }); + expect(panel).toHaveClass("quick-chat-panel--vv-height-smoothing"); + expect(panel.style.getPropertyValue("--vv-height")).toBe("760px"); + expect(panel.style.getPropertyValue("--vv-offset-top")).toBe("0px"); + const writesAfterFirstAndroidSample = styleWriteSpy.mock.calls.length; + + await driveQuickChatVisualViewport(visualViewport, { height: 760, offsetTop: 0, eventType: "scroll" }); + expect(styleWriteSpy.mock.calls.length).toBe(writesAfterFirstAndroidSample); + + await driveQuickChatVisualViewport(visualViewport, { height: 600, offsetTop: 0, eventType: "resize" }); + await driveQuickChatVisualViewport(visualViewport, { height: 520, offsetTop: 0, eventType: "scroll" }); + expect(panel.style.getPropertyValue("--vv-height")).toBe("520px"); + expect(panel.style.getPropertyValue("--vv-offset-top")).toBe("0px"); + + setQuickChatVisualViewportSample(visualViewport, { height: 500, offsetTop: 0 }); + fireEvent.focusIn(input); + expect(panel.style.getPropertyValue("--vv-height")).toBe("500px"); + + 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(panel).not.toHaveClass("quick-chat-panel--vv-height-smoothing"); + expect(screen.queryByTestId("quick-chat-resize-n")).toBeNull(); + + styleWriteSpy.mockRestore(); + }); + + it("FN-6757: keeps iOS offsetTop re-focus synchronous and outside Android smoothing", async () => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 800 }); + window.dispatchEvent(new Event("resize")); + mockUseViewportMode.mockReturnValue("mobile"); + mockUseMobileKeyboard.mockReturnValue({ + keyboardOverlap: 440, + viewportHeight: 360, + viewportOffsetTop: 24, + keyboardOpen: true, + }); + const visualViewport = mockQuickChatVisualViewport({ height: 800, offsetTop: 0 }); + + render(); + fireEvent.click(screen.getByTestId("quick-chat-fab")); + const panel = await screen.findByTestId("quick-chat-panel"); + const input = await screen.findByTestId("quick-chat-input"); + + Object.defineProperty(window, "innerHeight", { configurable: true, value: 360 }); + setQuickChatVisualViewportSample(visualViewport, { height: 360, offsetTop: 24 }); + fireEvent.focusIn(input); + + expect(panel.style.getPropertyValue("--vv-height")).toBe("360px"); + expect(panel.style.getPropertyValue("--vv-offset-top")).toBe("24px"); + expect(panel).not.toHaveClass("quick-chat-panel--vv-height-smoothing"); + + fireEvent.blur(input); + expect(panel.style.getPropertyValue("--vv-height")).toBe(""); + expect(panel.style.getPropertyValue("--vv-offset-top")).toBe(""); + expect(panel).not.toHaveClass("quick-chat-panel--vv-height-smoothing"); + }); + + it("FN-6757: resize smoothing preserves populated Latest gate and streaming state", async () => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 800 }); + 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 }); + mockFetchChatMessages.mockResolvedValueOnce({ + messages: [ + { id: "msg-populated", sessionId: "session-model", role: "assistant", content: "Existing answer", createdAt: "2026-06-19T00:00:00.000Z" }, + ], + }); + mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => { + handlers.onChunk?.("streaming answer"); + return { close: vi.fn(), isConnected: () => true }; + }); + + render(); + fireEvent.click(screen.getByTestId("quick-chat-fab")); + const panel = await screen.findByTestId("quick-chat-panel"); + const messages = await screen.findByTestId("quick-chat-messages"); + let scrollTopValue = 700; + Object.defineProperty(messages, "scrollHeight", { configurable: true, get: () => 1200 }); + Object.defineProperty(messages, "clientHeight", { configurable: true, get: () => 240 }); + Object.defineProperty(messages, "scrollTop", { + configurable: true, + get: () => scrollTopValue, + set: (value: number) => { + scrollTopValue = value; + }, + }); + + fireEvent.scroll(messages); + expect(screen.getByTestId("quick-chat-jump-to-latest")).toBeInTheDocument(); + + await driveQuickChatVisualViewport(visualViewport, { height: 760, offsetTop: 0, eventType: "resize" }); + await driveQuickChatVisualViewport(visualViewport, { height: 520, offsetTop: 0, eventType: "scroll" }); + expect(panel).toHaveClass("quick-chat-panel--vv-height-smoothing"); + expect(panel.style.getPropertyValue("--vv-height")).toBe("520px"); + expect(screen.getByTestId("quick-chat-jump-to-latest")).toBeInTheDocument(); + + fireEvent.change(screen.getByTestId("quick-chat-input"), { target: { value: "continue" } }); + fireEvent.click(screen.getByTestId("quick-chat-send")); + expect(await screen.findByTestId("quick-chat-streaming-message")).toBeInTheDocument(); + expect(screen.getByTestId("quick-chat-waiting")).toHaveTextContent("Working…"); + expect(panel.style.getPropertyValue("--vv-height")).toBe("520px"); + + fireEvent.click(screen.getByTestId("quick-chat-jump-to-latest")); + expect(scrollTopValue).toBe(1200); + }); + it("FN-6503: re-samples Android first-open keyboard settle on composer focus handoff", async () => { Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); window.dispatchEvent(new Event("resize"));