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
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -122,6 +122,35 @@ function createDeferredPromise<T>() {
|
||||
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(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||
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(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||
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(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||
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(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||
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"));
|
||||
|
||||
Reference in New Issue
Block a user