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
This commit is contained in:
5
.changeset/smooth-mobile-quick-chat-keyboard.md
Normal file
5
.changeset/smooth-mobile-quick-chat-keyboard.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||
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(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||
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(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||
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"));
|
||||
|
||||
Reference in New Issue
Block a user