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:
gsxdsm
2026-06-20 00:41:36 -07:00
parent 24fd4952bb
commit 2e3b965909
6 changed files with 174 additions and 0 deletions

View 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.

View File

@@ -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.

View File

@@ -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:

View File

@@ -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;

View File

@@ -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(() => {

View File

@@ -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"));