FN-6494: keep tablet chat sidebar visible
Keep tablet chat navigation available while software keyboards reduce the viewport. - Stop auto-hiding the tablet chat sidebar when the keyboard opens. - Bound the sidebar to its minimum width during tablet keyboard-open layout without overwriting the saved width. - Cover visible and user-collapsed tablet sidebar behavior in mobile render tests. - Add a patch changeset for the published Fusion package. Files changed: .changeset/fn-6494-tablet-chat-sidebar.md | 5 ++ packages/dashboard/app/components/ChatView.tsx | 34 +++-------- .../__tests__/ChatView.mobile-render.test.tsx | 70 ++++++++++++++++++++-- 3 files changed, 79 insertions(+), 30 deletions(-) Fusion-Task-Id: FN-6494 Fusion-Task-Lineage: 618cccc3-5102-4e9c-ac0e-6c7a44742940
This commit is contained in:
5
.changeset/fn-6494-tablet-chat-sidebar.md
Normal file
5
.changeset/fn-6494-tablet-chat-sidebar.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Keep the chat sidebar visible at a compact bounded width when a tablet software keyboard opens, then restore the previous width when the keyboard closes.
|
||||||
@@ -1105,7 +1105,6 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
// (which would swallow the next real tap and make the button look dead).
|
// (which would swallow the next real tap and make the button look dead).
|
||||||
const handledSendTouchRef = useRef(false);
|
const handledSendTouchRef = useRef(false);
|
||||||
const handledSendTouchTimerRef = useRef<number | null>(null);
|
const handledSendTouchTimerRef = useRef<number | null>(null);
|
||||||
const tabletKeyboardSidebarVisibilityRef = useRef<boolean | null>(null);
|
|
||||||
const mode = useViewportMode();
|
const mode = useViewportMode();
|
||||||
const isMobile = mode === "mobile";
|
const isMobile = mode === "mobile";
|
||||||
const isTablet = mode === "tablet";
|
const isTablet = mode === "tablet";
|
||||||
@@ -1222,29 +1221,6 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
});
|
});
|
||||||
const tabletKeyboardOpen = isTablet && keyboardOpen;
|
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 filteredSkills = useMemo(() => {
|
||||||
const normalizedFilter = skillFilter.trim().toLowerCase();
|
const normalizedFilter = skillFilter.trim().toLowerCase();
|
||||||
const matchingSkills = normalizedFilter
|
const matchingSkills = normalizedFilter
|
||||||
@@ -3050,12 +3026,20 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:ChatTabletKeyboard 2026-06-16-17:46:
|
||||||
|
* FN-6494 reverses the FN-6178/FN-6210 tablet-keyboard auto-hide: a visible chat sidebar must stay visible while the software keyboard is up, but use the minimum bounded width so the session list is not too wide in the reduced viewport. The user's persisted width remains untouched and returns when the keyboard closes; mobile keeps CSS-driven one-pane sizing.
|
||||||
|
*/
|
||||||
|
const sidebarInlineStyle: React.CSSProperties | undefined = isMobile
|
||||||
|
? undefined
|
||||||
|
: { width: `${tabletKeyboardOpen ? Math.min(sidebarWidth, CHAT_SIDEBAR_MIN_WIDTH) : sidebarWidth}px` };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-view">
|
<div className="chat-view">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div
|
<div
|
||||||
className={`chat-sidebar${!sidebarVisible ? " chat-sidebar--hidden" : ""}`}
|
className={`chat-sidebar${!sidebarVisible ? " chat-sidebar--hidden" : ""}`}
|
||||||
style={isMobile ? undefined : { width: `${sidebarWidth}px` }}
|
style={sidebarInlineStyle}
|
||||||
>
|
>
|
||||||
{chatRoomsEnabled && (
|
{chatRoomsEnabled && (
|
||||||
<div className="chat-sidebar-scope-toggle" role="tablist" data-testid="chat-sidebar-scope-toggle">
|
<div className="chat-sidebar-scope-toggle" role="tablist" data-testid="chat-sidebar-scope-toggle">
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ describe("FN-5997 mobile chat message pane rendering", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("auto-hides the tablet sidebar while the software keyboard is open and restores it when closed", async () => {
|
it("keeps the tablet sidebar visible but narrower while the software keyboard is open, and restores width when closed", async () => {
|
||||||
const restoreMatchMedia = mockViewportMode("tablet");
|
const restoreMatchMedia = mockViewportMode("tablet");
|
||||||
const visualViewport = mockVisualViewport({ width: 900, height: 1112 });
|
const visualViewport = mockVisualViewport({ width: 900, height: 1112 });
|
||||||
try {
|
try {
|
||||||
@@ -369,23 +369,83 @@ describe("FN-5997 mobile chat message pane rendering", () => {
|
|||||||
});
|
});
|
||||||
await setVisualViewportHeight(visualViewport, 560);
|
await setVisualViewportHeight(visualViewport, 560);
|
||||||
|
|
||||||
await waitFor(() => expect(sidebar).toHaveClass("chat-sidebar--hidden"));
|
await waitFor(() => expect(sidebar.style.width).toBe("180px"));
|
||||||
|
expect(sidebar).not.toHaveClass("chat-sidebar--hidden");
|
||||||
|
expect(Number.parseInt(sidebar.style.width, 10)).toBeLessThan(280);
|
||||||
|
expect(Number.parseInt(sidebar.style.width, 10)).toBeLessThanOrEqual(280);
|
||||||
expect(screen.queryByRole("separator", { name: "Resize chat sidebar" })).toBeNull();
|
expect(screen.queryByRole("separator", { name: "Resize chat sidebar" })).toBeNull();
|
||||||
expect(sidebar.style.width).toBe("280px");
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
input.blur();
|
input.blur();
|
||||||
});
|
});
|
||||||
await setVisualViewportHeight(visualViewport, 1112);
|
await setVisualViewportHeight(visualViewport, 1112);
|
||||||
|
|
||||||
await waitFor(() => expect(sidebar).not.toHaveClass("chat-sidebar--hidden"));
|
await waitFor(() => expect(sidebar.style.width).toBe("280px"));
|
||||||
expect(sidebar.style.width).toBe("280px");
|
expect(sidebar).not.toHaveClass("chat-sidebar--hidden");
|
||||||
expect(screen.getByRole("separator", { name: "Resize chat sidebar" })).toBeInTheDocument();
|
expect(screen.getByRole("separator", { name: "Resize chat sidebar" })).toBeInTheDocument();
|
||||||
} finally {
|
} finally {
|
||||||
restoreMatchMedia.mockRestore();
|
restoreMatchMedia.mockRestore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a user-collapsed sidebar collapsed across tablet keyboard open and close", async () => {
|
||||||
|
const restoreMatchMedia = mockViewportMode("mobile");
|
||||||
|
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();
|
||||||
|
await act(async () => {
|
||||||
|
screen.getByTestId(`chat-session-${activeSession.id}`).click();
|
||||||
|
});
|
||||||
|
expect(sidebar).toHaveClass("chat-sidebar--hidden");
|
||||||
|
|
||||||
|
Object.defineProperty(window, "innerWidth", { value: 900, configurable: true });
|
||||||
|
restoreMatchMedia.mockImplementation((query: string) => ({
|
||||||
|
matches: query.includes("min-width: 769px") && query.includes("max-width: 1024px"),
|
||||||
|
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"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sidebar).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, 560);
|
||||||
|
|
||||||
|
await waitFor(() => expect(sidebar.style.width).toBe("180px"));
|
||||||
|
expect(sidebar).toHaveClass("chat-sidebar--hidden");
|
||||||
|
expect(screen.queryByRole("separator", { name: "Resize chat sidebar" })).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
input.blur();
|
||||||
|
});
|
||||||
|
await setVisualViewportHeight(visualViewport, 1112);
|
||||||
|
|
||||||
|
await waitFor(() => expect(sidebar.style.width).toBe("280px"));
|
||||||
|
expect(sidebar).toHaveClass("chat-sidebar--hidden");
|
||||||
|
expect(screen.queryByRole("separator", { name: "Resize chat sidebar" })).toBeNull();
|
||||||
|
} finally {
|
||||||
|
restoreMatchMedia.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps sidebar width bounded even if viewport mode flickers to mobile during keyboard-open on tablet", async () => {
|
it("keeps sidebar width bounded even if viewport mode flickers to mobile during keyboard-open on tablet", async () => {
|
||||||
const restoreMatchMedia = mockViewportMode("tablet");
|
const restoreMatchMedia = mockViewportMode("tablet");
|
||||||
const originalScreenDescriptor = Object.getOwnPropertyDescriptor(window, "screen");
|
const originalScreenDescriptor = Object.getOwnPropertyDescriptor(window, "screen");
|
||||||
|
|||||||
Reference in New Issue
Block a user