feat(FN-5365): add direct thread viewport writes to fix mobile keyboard reg
Adds direct thread viewport write functionality to ChatView with corresponding mobile keyboard regression test coverage, plus lint and test suite restoration. Fusion-Task-Id: FN-5365
This commit is contained in:
committed by
gsxdsm
parent
6fb4b9ee47
commit
08d6535933
@@ -1,6 +1,6 @@
|
||||
// ChatView.css is imported eagerly from App.tsx to avoid a flash of
|
||||
// unstyled content when the lazy chunk loads. Do not re-import here.
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Components } from "react-markdown";
|
||||
@@ -999,6 +999,11 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
const directThreadDeferredAnchorTimeoutRef = useRef<number | null>(null);
|
||||
const hideSkillMenuTimeoutRef = useRef<number | null>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const chatThreadRef = useRef<HTMLDivElement | null>(null);
|
||||
// FN-5365: mirror QuickChat's mid-dismiss suppress gate so transient
|
||||
// visualViewport shrink samples do not jerk the chat thread/composer.
|
||||
const suppressVvShrinkRef = useRef(false);
|
||||
const suppressVvShrinkTimeoutRef = useRef<number | null>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const pendingAttachmentsRef = useRef<PendingAttachment[]>([]);
|
||||
@@ -1113,25 +1118,10 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
}, [activeDraftKey, messageInput]);
|
||||
|
||||
const roomThreadActive = chatRoomsEnabled && chatScope === "rooms" && !!rooms.activeRoom;
|
||||
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
|
||||
const { keyboardOverlap, keyboardOpen } = useMobileKeyboard({
|
||||
enabled: isMobile && (!!activeSession || roomThreadActive),
|
||||
});
|
||||
|
||||
// FN-5155: only publish keyboard-active viewport vars once the hook has a
|
||||
// self-consistent open sample. A transient offsetTop without vvHeight causes
|
||||
// mobile ChatView to shrink/translate the thread before the keyboard settles.
|
||||
const hasKeyboardViewportMetrics = viewportHeight !== null;
|
||||
const hasKeyboardViewportDisplacement = hasKeyboardViewportMetrics && (keyboardOverlap > 0 || viewportOffsetTop > 0);
|
||||
const threadKeyboardStyle: CSSProperties =
|
||||
keyboardOpen && hasKeyboardViewportDisplacement
|
||||
? ({
|
||||
"--keyboard-overlap": `${keyboardOverlap}px`,
|
||||
"--vv-offset-top": `${viewportOffsetTop}px`,
|
||||
"--vv-height": `${viewportHeight}px`,
|
||||
} as CSSProperties)
|
||||
: {};
|
||||
const threadClassName = `chat-thread${keyboardOpen && hasKeyboardViewportDisplacement ? " chat-thread--keyboard-active" : ""}`;
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
const normalizedFilter = skillFilter.trim().toLowerCase();
|
||||
const matchingSkills = normalizedFilter
|
||||
@@ -1335,6 +1325,57 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
// window.scrollTo(0, 0) on cleanup to recover from any iOS drift.
|
||||
useMobileScrollLock(isMobile && keyboardOpen);
|
||||
|
||||
// FN-5365: mirror QuickChatFAB keyboard handling by writing visualViewport
|
||||
// metrics directly to .chat-thread, avoiding React commit lag/jitter.
|
||||
useLayoutEffect(() => {
|
||||
if (!isMobile || (!activeSession && !roomThreadActive)) return;
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const thread = chatThreadRef.current;
|
||||
const vv = window.visualViewport;
|
||||
if (!thread || !vv) return;
|
||||
|
||||
const isKeyboardTrackingFocusable = (element: Element | null): boolean => {
|
||||
if (!(element instanceof HTMLElement)) return false;
|
||||
if (element.tagName === "TEXTAREA") return true;
|
||||
if (element.tagName !== "INPUT") return false;
|
||||
const inputType = (element as HTMLInputElement).type.toLowerCase();
|
||||
return ["", "text", "search", "email", "url", "tel", "password", "number"].includes(inputType);
|
||||
};
|
||||
|
||||
const apply = () => {
|
||||
if (suppressVvShrinkRef.current) {
|
||||
thread.classList.remove("chat-thread--keyboard-active");
|
||||
return;
|
||||
}
|
||||
const overlap = Math.max(0, window.innerHeight - vv.offsetTop - vv.height);
|
||||
const offsetTop = vv.offsetTop || 0;
|
||||
thread.style.setProperty("--vv-height", `${vv.height}px`);
|
||||
thread.style.setProperty("--vv-offset-top", `${offsetTop}px`);
|
||||
thread.style.setProperty("--keyboard-overlap", `${overlap}px`);
|
||||
|
||||
const keyboardActive = (overlap > 0 || offsetTop > 0) && isKeyboardTrackingFocusable(document.activeElement);
|
||||
thread.classList.toggle("chat-thread--keyboard-active", keyboardActive);
|
||||
};
|
||||
|
||||
apply();
|
||||
vv.addEventListener("resize", apply);
|
||||
vv.addEventListener("scroll", apply);
|
||||
document.addEventListener("focusin", apply);
|
||||
document.addEventListener("focusout", apply);
|
||||
window.addEventListener("pageshow", apply);
|
||||
document.addEventListener("visibilitychange", apply);
|
||||
return () => {
|
||||
vv.removeEventListener("resize", apply);
|
||||
vv.removeEventListener("scroll", apply);
|
||||
document.removeEventListener("focusin", apply);
|
||||
document.removeEventListener("focusout", apply);
|
||||
window.removeEventListener("pageshow", apply);
|
||||
document.removeEventListener("visibilitychange", apply);
|
||||
thread.classList.remove("chat-thread--keyboard-active");
|
||||
};
|
||||
}, [activeSession, isMobile, roomThreadActive]);
|
||||
|
||||
// Close context menu on outside click
|
||||
useEffect(() => {
|
||||
const handleClick = () => setContextMenu(null);
|
||||
@@ -1956,6 +1997,17 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
);
|
||||
|
||||
const handleInputBlur = useCallback(() => {
|
||||
if (typeof window !== "undefined" && window.innerWidth <= 768) {
|
||||
suppressVvShrinkRef.current = true;
|
||||
if (suppressVvShrinkTimeoutRef.current !== null) {
|
||||
window.clearTimeout(suppressVvShrinkTimeoutRef.current);
|
||||
}
|
||||
suppressVvShrinkTimeoutRef.current = window.setTimeout(() => {
|
||||
suppressVvShrinkRef.current = false;
|
||||
suppressVvShrinkTimeoutRef.current = null;
|
||||
}, 450);
|
||||
}
|
||||
|
||||
if (hideSkillMenuTimeoutRef.current !== null) {
|
||||
window.clearTimeout(hideSkillMenuTimeoutRef.current);
|
||||
}
|
||||
@@ -1972,6 +2024,11 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
}, [fileMention]);
|
||||
|
||||
const handleInputFocus = useCallback(() => {
|
||||
suppressVvShrinkRef.current = false;
|
||||
if (suppressVvShrinkTimeoutRef.current !== null) {
|
||||
window.clearTimeout(suppressVvShrinkTimeoutRef.current);
|
||||
suppressVvShrinkTimeoutRef.current = null;
|
||||
}
|
||||
if (hideSkillMenuTimeoutRef.current !== null) {
|
||||
window.clearTimeout(hideSkillMenuTimeoutRef.current);
|
||||
hideSkillMenuTimeoutRef.current = null;
|
||||
@@ -1994,6 +2051,14 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (suppressVvShrinkTimeoutRef.current !== null) {
|
||||
window.clearTimeout(suppressVvShrinkTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle archive
|
||||
const handleArchive = useCallback(
|
||||
async (id: string) => {
|
||||
@@ -2639,7 +2704,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
)}
|
||||
{/* Thread */}
|
||||
{chatRoomsEnabled && chatScope === "rooms" ? (
|
||||
<div className={threadClassName} style={threadKeyboardStyle}>
|
||||
<div ref={chatThreadRef} className="chat-thread">
|
||||
{rooms.activeRoom ? (
|
||||
<>
|
||||
<div className="chat-room-thread-header">
|
||||
@@ -2822,7 +2887,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={threadClassName} style={threadKeyboardStyle}>
|
||||
<div ref={chatThreadRef} className="chat-thread">
|
||||
{/* Header - always rendered in desktop/tablet, only rendered in mobile when viewing a thread */}
|
||||
{(hasThreadInView || !isMobile) && (
|
||||
<div className="chat-thread-header">
|
||||
|
||||
@@ -560,7 +560,6 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
|
||||
document.dispatchEvent(new Event("focusin"));
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "innerHeight", { value: 560, configurable: true, writable: true });
|
||||
Object.defineProperty(mockVV, "height", { value: 560, configurable: true, writable: true });
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
|
||||
@@ -3586,7 +3586,7 @@ describe("ChatView mobile behavior", () => {
|
||||
|
||||
const thread = document.querySelector(".chat-thread") as HTMLDivElement;
|
||||
expect(thread).toBeInTheDocument();
|
||||
expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||||
expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("0px");
|
||||
|
||||
// Focus the chat textarea so the hook treats the active element as a
|
||||
// keyboard-focusable target.
|
||||
@@ -3598,11 +3598,6 @@ describe("ChatView mobile behavior", () => {
|
||||
document.dispatchEvent(new Event("focusin"));
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: 560,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(mockVV, "height", {
|
||||
value: 560,
|
||||
writable: true,
|
||||
@@ -3632,8 +3627,8 @@ describe("ChatView mobile behavior", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||||
expect(thread.style.getPropertyValue("--vv-height")).toBe("");
|
||||
expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("284px");
|
||||
expect(thread.style.getPropertyValue("--vv-height")).toBe("560px");
|
||||
});
|
||||
} finally {
|
||||
restoreMatchMedia.mockRestore();
|
||||
@@ -3684,9 +3679,9 @@ describe("ChatView mobile behavior", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("FN-5155: mobile mode does not apply keyboard-active styles for impossible mid-transition viewport samples", async () => {
|
||||
it("FN-5365: mobile keyboard viewport vars follow settled sample and suppress blur-dismiss shrink", async () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
const { mockVV } = mockMobileVisualViewport({
|
||||
const { listeners, mockVV } = mockMobileVisualViewport({
|
||||
innerHeight: 844,
|
||||
vvHeight: 844,
|
||||
});
|
||||
@@ -3701,34 +3696,71 @@ describe("ChatView mobile behavior", () => {
|
||||
|
||||
const thread = document.querySelector(".chat-thread") as HTMLDivElement;
|
||||
expect(thread).toBeInTheDocument();
|
||||
expect(thread.style.getPropertyValue("--vv-height")).toBe("844px");
|
||||
expect(thread.style.getPropertyValue("--vv-offset-top")).toBe("0px");
|
||||
expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("0px");
|
||||
|
||||
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
||||
await act(async () => {
|
||||
textarea.focus();
|
||||
});
|
||||
|
||||
Object.defineProperty(mockVV, "offsetTop", {
|
||||
value: 180,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(mockVV, "height", {
|
||||
value: 820,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("focusin"));
|
||||
});
|
||||
|
||||
Object.defineProperty(mockVV, "offsetTop", { value: 180, writable: true, configurable: true });
|
||||
Object.defineProperty(mockVV, "height", { value: 820, writable: true, configurable: true });
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
});
|
||||
expect(thread.style.getPropertyValue("--vv-height")).toBe("820px");
|
||||
|
||||
Object.defineProperty(mockVV, "offsetTop", { value: 0, writable: true, configurable: true });
|
||||
Object.defineProperty(mockVV, "height", { value: 560, writable: true, configurable: true });
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(thread.style.getPropertyValue("--vv-height")).toBe("560px");
|
||||
expect(thread.style.getPropertyValue("--vv-offset-top")).toBe("0px");
|
||||
expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("284px");
|
||||
expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(true);
|
||||
});
|
||||
|
||||
const styleAfterVvEvents = thread.getAttribute("style") ?? "";
|
||||
expect(styleAfterVvEvents).toContain("--vv-height: 560px");
|
||||
expect(styleAfterVvEvents).toContain("--vv-offset-top: 0px");
|
||||
expect(styleAfterVvEvents).toContain("--keyboard-overlap: 284px");
|
||||
|
||||
textarea.blur();
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("focusout"));
|
||||
});
|
||||
await waitFor(() => {
|
||||
// FN-5155 fix keeps transient impossible metrics out of ChatView until
|
||||
// the viewport settles, so the composer never floats above the keyboard.
|
||||
expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(false);
|
||||
expect(thread.style.getPropertyValue("--vv-height")).toBe("");
|
||||
expect(thread.style.getPropertyValue("--vv-offset-top")).toBe("");
|
||||
expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||||
});
|
||||
|
||||
Object.defineProperty(mockVV, "height", { value: 700, writable: true, configurable: true });
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
});
|
||||
expect(thread.style.getPropertyValue("--vv-height")).toBe("560px");
|
||||
|
||||
await act(async () => {
|
||||
textarea.focus();
|
||||
});
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("focusin"));
|
||||
});
|
||||
|
||||
Object.defineProperty(mockVV, "height", { value: 640, writable: true, configurable: true });
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(thread.style.getPropertyValue("--vv-height")).toBe("640px");
|
||||
});
|
||||
} finally {
|
||||
restoreMatchMedia.mockRestore();
|
||||
@@ -3762,7 +3794,6 @@ describe("ChatView mobile behavior", () => {
|
||||
});
|
||||
|
||||
Object.defineProperty(mockVV, "height", { value: 560, writable: true, configurable: true });
|
||||
Object.defineProperty(window, "innerHeight", { value: 560, writable: true, configurable: true });
|
||||
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
|
||||
Reference in New Issue
Block a user