From 17363928e93f7a34a3944f942347f0e0f709e7c1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 10 Jun 2026 00:16:35 -0700 Subject: [PATCH] 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 --- .../dashboard/app/components/ChatView.tsx | 39 ++++- .../__tests__/ChatView.mobile-render.test.tsx | 139 +++++++++++++++++- .../dashboard/app/hooks/useMobileKeyboard.ts | 7 +- 3 files changed, 171 insertions(+), 14 deletions(-) diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index b471e771b4..04b646e992 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -1090,8 +1090,10 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView const mentionCursorPosRef = useRef(0); const copyFeedbackTimeoutsRef = useRef>(new Map()); const roomSendInFlightRef = useRef(false); + const tabletKeyboardSidebarVisibilityRef = useRef(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) => { - 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) => { - 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 )} - {!isMobile && sidebarVisible && ( + {!isMobile && sidebarVisible && !tabletKeyboardOpen && (
({ - 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(); + + 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(); + + 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(); + + 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(); + } + }); }); diff --git a/packages/dashboard/app/hooks/useMobileKeyboard.ts b/packages/dashboard/app/hooks/useMobileKeyboard.ts index 5ebdb9d7cd..fca5d3497c 100644 --- a/packages/dashboard/app/hooks/useMobileKeyboard.ts +++ b/packages/dashboard/app/hooks/useMobileKeyboard.ts @@ -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(null); @@ -156,7 +157,7 @@ export function useMobileKeyboard( const stableMetricsRef = useRef(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 }; }