FN-6210: cap chat sidebar width during keyboard flicker

Keep the chat sidebar bounded when tablet keyboard viewport changes briefly report mobile sizing.

- Add a desktop max-width guard to the chat sidebar with a mobile override.
- Cover simulated tablet keyboard viewport-mode flicker in the mobile render tests.
- Document the ChatView sidebar defense alongside the tablet viewport-mode solution.

Files changed:
 .../ui-bugs/tablet-keyboard-viewport-mode-flip.md  |  3 ++
 packages/dashboard/app/components/ChatView.css     |  2 +
 .../__tests__/ChatView.mobile-render.test.tsx      | 53 ++++++++++++++++++++++
 3 files changed, 58 insertions(+)

Fusion-Task-Id: FN-6210

Fusion-Task-Lineage: b3aca9d5-dc16-45e2-8f2c-5c2d51c7d021
This commit is contained in:
gsxdsm
2026-06-11 19:51:19 -07:00
parent 02ae5a9340
commit 1c2b68386b
3 changed files with 58 additions and 0 deletions

View File

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

View File

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

View File

@@ -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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
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 });