diff --git a/docs/solutions/ui-bugs/tablet-keyboard-viewport-mode-flip.md b/docs/solutions/ui-bugs/tablet-keyboard-viewport-mode-flip.md index c2034fc565..e1b80a123d 100644 --- a/docs/solutions/ui-bugs/tablet-keyboard-viewport-mode-flip.md +++ b/docs/solutions/ui-bugs/tablet-keyboard-viewport-mode-flip.md @@ -42,6 +42,8 @@ Keep the exported `MOBILE_MEDIA_QUERY` string unchanged for listener compatibili This preserves landscape-phone behavior while preventing keyboard-driven height shrink from changing tablet/desktop viewport mode. Direct breakpoint consumers (`Board`, `WorkflowNodeEditor`, and `SessionTerminal`) should subscribe to `MOBILE_MEDIA_QUERY` for reactivity but recompute state with `isMobileViewport()` rather than reading `.matches` as the final decision. +ChatView also keeps a defense-in-depth CSS guard from FN-6210: `.chat-sidebar` has a non-mobile `max-width` matching `CHAT_SIDEBAR_MAX_WIDTH`, with the mobile media rule overriding it back to `100%`. That guard bounds the sidebar even if viewport-mode state is temporarily wrong and the inline sidebar width is removed. + ## Regression coverage Cover the invariant rather than the single repro: @@ -52,3 +54,4 @@ Cover the invariant rather than the single repro: - Portrait phone width stays `mobile` regardless of height. - Undefined/zero `window.screen` does not throw and falls back to width-only detection. - Component-local mobile hooks such as `SessionTerminal` also use the guarded predicate. +- ChatView sidebar CSS remains bounded at the sidebar max width during simulated viewport-mode flicker while the keyboard is open. diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index 558488c7c5..f2d97623ef 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -14,6 +14,7 @@ /* Sidebar */ .chat-sidebar { min-width: 0; + max-width: 500px; /* FN-6210: defensive cap — matches CHAT_SIDEBAR_MAX_WIDTH */ border-right: 1px solid var(--border); display: flex; flex-direction: column; @@ -1725,6 +1726,7 @@ .chat-sidebar { width: 100%; min-width: 100%; + max-width: 100%; /* FN-6210: mobile sidebar spans full viewport */ height: 100%; max-height: none; border-right: none; diff --git a/packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx index d5e60b10c7..638c9de5b7 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.mobile-render.test.tsx @@ -386,6 +386,59 @@ describe("FN-5997 mobile chat message pane rendering", () => { } }); + it("keeps sidebar width bounded even if viewport mode flickers to mobile during keyboard-open on tablet", async () => { + const restoreMatchMedia = mockViewportMode("tablet"); + const originalScreenDescriptor = Object.getOwnPropertyDescriptor(window, "screen"); + 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"); + + const input = screen.getByTestId("chat-input") as HTMLTextAreaElement; + await act(async () => { + input.focus(); + }); + await setVisualViewportHeight(visualViewport, 400); + + // Simulate the FN-6213 bug scenario where viewport mode transiently + // resolves to mobile on a tablet while the keyboard has shrunk height. + Object.defineProperty(window, "screen", { configurable: true, value: { width: 390, height: 844 } }); + restoreMatchMedia.mockImplementation((query: string) => ({ + matches: + query.includes("max-width: 768px") || + query.includes("max-height: 480px"), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + await act(async () => { + window.dispatchEvent(new Event("resize")); + }); + + await waitFor(() => expect(sidebar.style.width).toBe("")); + const maxWidth = parseInt(getComputedStyle(sidebar).maxWidth, 10); + expect(maxWidth).toBeLessThanOrEqual(500); + expect(sidebar.offsetWidth).toBeLessThanOrEqual(500); + } finally { + restoreMatchMedia.mockRestore(); + if (originalScreenDescriptor) { + Object.defineProperty(window, "screen", originalScreenDescriptor); + } + } + }); + 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 });