FN-6178: hide chat sidebar when tablet keyboard opens
Keep the chat sidebar from crowding tablet chat while the software keyboard is open. - extend mobile keyboard detection so tablet chat can react to visual viewport keyboard changes - hide the chat sidebar and resize handle while the tablet keyboard is open, then restore prior visibility when it closes - add regression coverage for tablet keyboard behavior plus desktop and mobile guardrails Files changed: packages/dashboard/app/components/ChatView.tsx | 39 +++++- packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx | 139 ++++++++++++++++++++- packages/dashboard/app/hooks/useMobileKeyboard.ts | 7 +- 3 files changed, 171 insertions(+), 14 deletions(-) Fusion-Task-Id: FN-6178 Fusion-Task-Lineage: 7acdb76a-135f-4b9c-ab50-708eec03b2c6
This commit is contained in:
@@ -1090,8 +1090,10 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
const mentionCursorPosRef = useRef(0);
|
||||
const copyFeedbackTimeoutsRef = useRef<Map<string, number>>(new Map());
|
||||
const roomSendInFlightRef = useRef(false);
|
||||
const tabletKeyboardSidebarVisibilityRef = useRef<boolean | null>(null);
|
||||
const mode = useViewportMode();
|
||||
const isMobile = mode === "mobile";
|
||||
const isTablet = mode === "tablet";
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSession?.id) {
|
||||
@@ -1200,8 +1202,33 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
|
||||
const roomThreadActive = chatRoomsEnabled && chatScope === "rooms" && !!rooms.activeRoom;
|
||||
const { keyboardOverlap, keyboardOpen } = useMobileKeyboard({
|
||||
enabled: isMobile && (!!activeSession || roomThreadActive),
|
||||
enabled: (isMobile || isTablet) && (!!activeSession || roomThreadActive),
|
||||
allowNonMobileViewport: isTablet,
|
||||
});
|
||||
const tabletKeyboardOpen = isTablet && keyboardOpen;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTablet) {
|
||||
tabletKeyboardSidebarVisibilityRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyboardOpen) {
|
||||
setSidebarVisible((currentSidebarVisible) => {
|
||||
if (tabletKeyboardSidebarVisibilityRef.current === null) {
|
||||
tabletKeyboardSidebarVisibilityRef.current = currentSidebarVisible;
|
||||
}
|
||||
return currentSidebarVisible ? false : currentSidebarVisible;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (tabletKeyboardSidebarVisibilityRef.current !== null) {
|
||||
const shouldRestoreSidebar = tabletKeyboardSidebarVisibilityRef.current;
|
||||
tabletKeyboardSidebarVisibilityRef.current = null;
|
||||
setSidebarVisible(shouldRestoreSidebar);
|
||||
}
|
||||
}, [isTablet, keyboardOpen]);
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
const normalizedFilter = skillFilter.trim().toLowerCase();
|
||||
@@ -2334,7 +2361,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
}, []);
|
||||
|
||||
const handleResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (isMobile) {
|
||||
if (isMobile || tabletKeyboardOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2373,10 +2400,10 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
|
||||
document.addEventListener("pointermove", onPointerMove);
|
||||
document.addEventListener("pointerup", onPointerUp);
|
||||
}, [isMobile, persistSidebarWidth, sidebarWidth]);
|
||||
}, [isMobile, persistSidebarWidth, sidebarWidth, tabletKeyboardOpen]);
|
||||
|
||||
const handleResizeKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (isMobile) {
|
||||
if (isMobile || tabletKeyboardOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2391,7 +2418,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
const nextWidth = Math.max(CHAT_SIDEBAR_MIN_WIDTH, Math.min(CHAT_SIDEBAR_MAX_WIDTH, sidebarWidth + delta));
|
||||
setSidebarWidth(nextWidth);
|
||||
persistSidebarWidth(nextWidth);
|
||||
}, [isMobile, persistSidebarWidth, sidebarWidth]);
|
||||
}, [isMobile, persistSidebarWidth, sidebarWidth, tabletKeyboardOpen]);
|
||||
|
||||
// Handle session click
|
||||
const handleSessionClick = useCallback(
|
||||
@@ -3150,7 +3177,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isMobile && sidebarVisible && (
|
||||
{!isMobile && sidebarVisible && !tabletKeyboardOpen && (
|
||||
<div
|
||||
className="chat-sidebar-resize-handle"
|
||||
role="separator"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen, within } from "@testing-library/react";
|
||||
import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { ChatView } from "../ChatView";
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
import * as useChatModule from "../../hooks/useChat";
|
||||
@@ -103,12 +103,18 @@ function ensureMatchMedia() {
|
||||
});
|
||||
}
|
||||
|
||||
function mockViewportMode(mode: "mobile" | "desktop") {
|
||||
function mockViewportMode(mode: "mobile" | "tablet" | "desktop") {
|
||||
ensureMatchMedia();
|
||||
const isMobile = mode === "mobile";
|
||||
Object.defineProperty(window, "innerWidth", { value: isMobile ? 375 : 1280, configurable: true });
|
||||
const widthByMode = {
|
||||
mobile: 375,
|
||||
tablet: 900,
|
||||
desktop: 1280,
|
||||
};
|
||||
Object.defineProperty(window, "innerWidth", { value: widthByMode[mode], configurable: true });
|
||||
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||||
matches: (isMobile && query === "(max-width: 768px)") || query === "(max-width: 768px), (max-height: 480px)",
|
||||
matches:
|
||||
(mode === "mobile" && query.includes("max-width: 768px")) ||
|
||||
(mode === "tablet" && query.includes("min-width: 769px") && query.includes("max-width: 1024px")),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
@@ -119,6 +125,34 @@ function mockViewportMode(mode: "mobile" | "desktop") {
|
||||
}));
|
||||
}
|
||||
|
||||
function mockVisualViewport({ height, width }: { height: number; width: number }) {
|
||||
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: 0, 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 });
|
||||
Object.defineProperty(window, "innerHeight", { value: height, configurable: true });
|
||||
Object.defineProperty(document.documentElement, "clientHeight", { value: height, configurable: true });
|
||||
return visualViewport;
|
||||
}
|
||||
|
||||
async function setVisualViewportHeight(visualViewport: VisualViewport, height: number) {
|
||||
Object.defineProperty(visualViewport, "height", { value: height, writable: true, configurable: true });
|
||||
await act(async () => {
|
||||
visualViewport.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
}
|
||||
|
||||
function getSidebar() {
|
||||
return document.querySelector(".chat-sidebar") as HTMLElement;
|
||||
}
|
||||
|
||||
async function renderWithCss(ui: JSX.Element) {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = css;
|
||||
@@ -312,4 +346,99 @@ describe("FN-5997 mobile chat message pane rendering", () => {
|
||||
restoreMatchMedia.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-hides the tablet sidebar while the software keyboard is open and restores it when closed", async () => {
|
||||
const restoreMatchMedia = mockViewportMode("tablet");
|
||||
const visualViewport = mockVisualViewport({ width: 900, height: 1112 });
|
||||
try {
|
||||
setupChat({
|
||||
sessions: [activeSession],
|
||||
filteredSessions: [activeSession],
|
||||
activeSession,
|
||||
});
|
||||
await renderWithCss(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const sidebar = getSidebar();
|
||||
expect(sidebar).not.toHaveClass("chat-sidebar--hidden");
|
||||
expect(sidebar.style.width).toBe("280px");
|
||||
expect(screen.getByRole("separator", { name: "Resize chat sidebar" })).toBeInTheDocument();
|
||||
|
||||
const input = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
||||
await act(async () => {
|
||||
input.focus();
|
||||
});
|
||||
await setVisualViewportHeight(visualViewport, 560);
|
||||
|
||||
await waitFor(() => expect(sidebar).toHaveClass("chat-sidebar--hidden"));
|
||||
expect(screen.queryByRole("separator", { name: "Resize chat sidebar" })).toBeNull();
|
||||
expect(sidebar.style.width).toBe("280px");
|
||||
|
||||
await act(async () => {
|
||||
input.blur();
|
||||
});
|
||||
await setVisualViewportHeight(visualViewport, 1112);
|
||||
|
||||
await waitFor(() => expect(sidebar).not.toHaveClass("chat-sidebar--hidden"));
|
||||
expect(sidebar.style.width).toBe("280px");
|
||||
expect(screen.getByRole("separator", { name: "Resize chat sidebar" })).toBeInTheDocument();
|
||||
} finally {
|
||||
restoreMatchMedia.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the desktop sidebar fixed even if visualViewport shrinks while the composer is focused", async () => {
|
||||
const restoreMatchMedia = mockViewportMode("desktop");
|
||||
const visualViewport = mockVisualViewport({ width: 1280, height: 900 });
|
||||
try {
|
||||
setupChat({
|
||||
sessions: [activeSession],
|
||||
filteredSessions: [activeSession],
|
||||
activeSession,
|
||||
});
|
||||
await renderWithCss(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const sidebar = getSidebar();
|
||||
const input = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
||||
await act(async () => {
|
||||
input.focus();
|
||||
});
|
||||
await setVisualViewportHeight(visualViewport, 560);
|
||||
|
||||
expect(sidebar).not.toHaveClass("chat-sidebar--hidden");
|
||||
expect(sidebar.style.width).toBe("280px");
|
||||
expect(screen.getByRole("separator", { name: "Resize chat sidebar" })).toBeInTheDocument();
|
||||
} finally {
|
||||
restoreMatchMedia.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the existing mobile sidebar behavior unchanged when the keyboard opens", async () => {
|
||||
const restoreMatchMedia = mockViewportMode("mobile");
|
||||
const visualViewport = mockVisualViewport({ width: 375, height: 812 });
|
||||
try {
|
||||
setupChat({
|
||||
sessions: [activeSession],
|
||||
filteredSessions: [activeSession],
|
||||
activeSession,
|
||||
});
|
||||
await renderWithCss(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const sidebar = getSidebar();
|
||||
const initiallyHidden = sidebar.classList.contains("chat-sidebar--hidden");
|
||||
expect(sidebar.style.width).toBe("");
|
||||
expect(screen.queryByRole("separator", { name: "Resize chat sidebar" })).toBeNull();
|
||||
|
||||
const input = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
||||
await act(async () => {
|
||||
input.focus();
|
||||
});
|
||||
await setVisualViewportHeight(visualViewport, 500);
|
||||
|
||||
expect(sidebar.classList.contains("chat-sidebar--hidden")).toBe(initiallyHidden);
|
||||
expect(sidebar.style.width).toBe("");
|
||||
expect(screen.queryByRole("separator", { name: "Resize chat sidebar" })).toBeNull();
|
||||
} finally {
|
||||
restoreMatchMedia.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -144,10 +144,11 @@ export function _resetInitialViewportHeight(): void {
|
||||
|
||||
interface UseMobileKeyboardOptions {
|
||||
enabled?: boolean;
|
||||
allowNonMobileViewport?: boolean;
|
||||
}
|
||||
|
||||
export function useMobileKeyboard(
|
||||
{ enabled = true }: UseMobileKeyboardOptions = {},
|
||||
{ enabled = true, allowNonMobileViewport = false }: UseMobileKeyboardOptions = {},
|
||||
): { keyboardOverlap: number; viewportHeight: number | null; viewportOffsetTop: number; keyboardOpen: boolean } {
|
||||
const [keyboardOverlap, setKeyboardOverlap] = useState(0);
|
||||
const [viewportHeight, setViewportHeight] = useState<number | null>(null);
|
||||
@@ -156,7 +157,7 @@ export function useMobileKeyboard(
|
||||
const stableMetricsRef = useRef<KeyboardMetrics>(CLOSED_KEYBOARD_METRICS);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !isMobileDevice()) {
|
||||
if (!enabled || (!allowNonMobileViewport && !isMobileDevice())) {
|
||||
setKeyboardOverlap(0);
|
||||
setViewportHeight(null);
|
||||
setViewportOffsetTop(0);
|
||||
@@ -316,7 +317,7 @@ export function useMobileKeyboard(
|
||||
setViewportOffsetTop(0);
|
||||
setKeyboardOpen(false);
|
||||
};
|
||||
}, [enabled]);
|
||||
}, [allowNonMobileViewport, enabled]);
|
||||
|
||||
return { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user