🚪 fix: Align Mobile Sidebar Toggle Gating with JS Breakpoint Across Views (#13654)

* 🚪 fix: Align Mobile Sidebar Toggle Gating with JS Breakpoint Across Views

* 🚪 fix: Sidebar Toggle on Read-Only Prompt Details View
This commit is contained in:
Danny Avila
2026-06-10 15:31:56 -04:00
committed by GitHub
parent dffd27f883
commit 0d24cbd496
6 changed files with 113 additions and 44 deletions

View File

@@ -218,17 +218,17 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
{/* Sticky wrapper for search bar and categories */}
<div className="sticky top-0 z-10 mt-4 bg-presentation pb-4 md:mt-0">
<div className="container mx-auto max-w-4xl px-4">
<div className="mx-auto mb-3 flex max-w-2xl items-center justify-between gap-2 md:hidden">
<OpenSidebar />
<MarketplaceAdminSettings compact />
</div>
{isSmallScreen ? (
<div className="mx-auto mb-3 flex max-w-2xl items-center justify-between gap-2">
<OpenSidebar />
<MarketplaceAdminSettings compact />
</div>
) : null}
{/* Search bar */}
<div className="mx-auto flex max-w-2xl gap-2 pb-6">
<SearchBar value={searchQuery} onSearch={handleSearch} />
{/* TODO: Remove this once we have a better way to handle admin settings */}
<div className="hidden md:block">
<MarketplaceAdminSettings />
</div>
{!isSmallScreen && <MarketplaceAdminSettings />}
</div>
{/* Category tabs */}

View File

@@ -45,7 +45,7 @@ function Header() {
<div className="via-presentation/70 md:from-presentation/80 md:via-presentation/50 2xl:from-presentation/0 absolute top-0 z-10 flex h-[52px] w-full items-center justify-between bg-gradient-to-b from-presentation to-transparent p-2 font-semibold text-text-primary 2xl:via-transparent">
<div className="hide-scrollbar flex w-full items-center justify-between gap-2 overflow-x-auto">
<div className="mx-1 flex items-center">
<OpenSidebar className="md:hidden" />
{isSmallScreen ? <OpenSidebar /> : null}
{!(navVisible && isSmallScreen) && (
<div
className={cn(

View File

@@ -1,18 +1,18 @@
import { useEffect } from 'react';
import { FileText } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Button, TextareaAutosize, Input } from '@librechat/client';
import { useForm, Controller, FormProvider } from 'react-hook-form';
import { Button, TextareaAutosize, Input, useMediaQuery } from '@librechat/client';
import { LocalStorageKeys, PermissionTypes, Permissions } from 'librechat-data-provider';
import OpenSidebar from '~/components/Chat/Menus/OpenSidebar';
import CategorySelector from '../fields/CategorySelector';
import VariablesDropdown from '../editor/VariablesDropdown';
import CategorySelector from '../fields/CategorySelector';
import PromptVariables from '../display/PromptVariables';
import Description from '../fields/Description';
import { usePromptGroupsContext } from '~/Providers';
import { useLocalize, useHasAccess } from '~/hooks';
import Command from '../fields/Command';
import { useCreatePrompt } from '~/data-provider';
import Description from '../fields/Description';
import Command from '../fields/Command';
import { cn } from '~/utils';
type CreateFormValues = {
@@ -42,6 +42,7 @@ const CreatePromptForm = ({
}) => {
const localize = useLocalize();
const navigate = useNavigate();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const { hasAccess: hasUseAccess } = usePromptGroupsContext() ?? {};
const hasCreateAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
@@ -114,10 +115,12 @@ const CreatePromptForm = ({
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="w-full px-4 py-2">
<h1 className="sr-only">{localize('com_ui_create_prompt_page')}</h1>
<div className="mb-2 flex items-center justify-between gap-2 sm:hidden">
<OpenSidebar />
<CategorySelector />
</div>
{isSmallScreen ? (
<div className="mb-2 flex items-center justify-between gap-2">
<OpenSidebar />
<CategorySelector />
</div>
) : null}
<div className="mb-1 flex flex-col items-center justify-between font-bold sm:text-xl md:mb-0 md:text-2xl">
<div className="flex w-full flex-col items-center justify-between sm:flex-row">
<Controller
@@ -153,9 +156,11 @@ const CreatePromptForm = ({
</div>
)}
/>
<div className="hidden sm:block">
<CategorySelector />
</div>
{!isSmallScreen && (
<div>
<CategorySelector />
</div>
)}
</div>
</div>
<div className="flex w-full flex-col gap-4 md:mt-[1.075rem]">

View File

@@ -1,10 +1,10 @@
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import debounce from 'lodash/debounce';
import { useRecoilValue } from 'recoil';
import { Menu, Rocket, X } from 'lucide-react';
import { useParams } from 'react-router-dom';
import { Menu, Rocket, X } from 'lucide-react';
import { useForm, FormProvider } from 'react-hook-form';
import { Button, Skeleton, useToastContext } from '@librechat/client';
import { Button, Skeleton, useToastContext, useMediaQuery } from '@librechat/client';
import {
Permissions,
ResourceType,
@@ -30,8 +30,8 @@ import DeletePrompt from '../dialogs/DeletePrompt';
import NoPromptGroup from '../lists/NoPromptGroup';
import PromptEditor from '../editor/PromptEditor';
import SkeletonForm from '../utils/SkeletonForm';
import Description from '../fields/Description';
import SharePrompt from '../dialogs/SharePrompt';
import Description from '../fields/Description';
import PromptName from '../fields/PromptName';
import { cn, findPromptGroup } from '~/utils';
import { PromptsEditorMode } from '~/common';
@@ -187,6 +187,7 @@ const PromptForm = ({ promptId: promptIdProp }: { promptId?: string }) => {
const promptId = promptIdProp || params.promptId || '';
const editorMode = useRecoilValue(store.promptsEditorMode);
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const [selectionIndex, setSelectionIndex] = useState<number>(0);
const prevIsEditingRef = useRef(false);
@@ -437,7 +438,16 @@ const PromptForm = ({ promptId: promptIdProp }: { promptId?: string }) => {
}
if (fetchedPrompt || group) {
return <PromptDetails group={fetchedPrompt || group} showActions={false} />;
return (
<div className="flex h-full w-full flex-col">
{isSmallScreen && (
<div className="flex shrink-0 items-center px-4 pt-3">
<OpenSidebar />
</div>
)}
<PromptDetails group={fetchedPrompt || group} showActions={false} />
</div>
);
}
}
@@ -462,8 +472,8 @@ const PromptForm = ({ promptId: promptIdProp }: { promptId?: string }) => {
<div className="flex h-full">
<div className="flex-1 overflow-hidden px-4">
{/* Mobile Actions Row */}
{!isLoadingGroup && group && (
<div className="mb-3 mt-2 flex items-center justify-between gap-2 sm:hidden">
{!isLoadingGroup && group && isSmallScreen && (
<div className="mb-3 mt-2 flex items-center justify-between gap-2">
<OpenSidebar />
<HeaderActions
group={group}
@@ -510,15 +520,17 @@ const PromptForm = ({ promptId: promptIdProp }: { promptId?: string }) => {
</Button>
)}
</div>
<div className="hidden shrink-0 sm:block">
<HeaderActions
group={group}
canEdit={canEdit}
canDelete={canDelete}
selectedPromptId={selectedPromptId}
onCategoryChange={handleCategoryChange}
/>
</div>
{!isSmallScreen && (
<div className="shrink-0">
<HeaderActions
group={group}
canEdit={canEdit}
canDelete={canDelete}
selectedPromptId={selectedPromptId}
onCategoryChange={handleCategoryChange}
/>
</div>
)}
</>
)}
</div>

View File

@@ -1,12 +1,13 @@
import { Navigate, useNavigate, useParams, useLocation, useSearchParams } from 'react-router-dom';
import { Spinner } from '@librechat/client';
import { Spinner, useMediaQuery } from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import { useGetSkillByIdQuery } from '~/data-provider';
import { useHasAccess, useAuthContext, useLocalize } from '~/hooks';
import { Navigate, useNavigate, useParams, useLocation, useSearchParams } from 'react-router-dom';
import SkillFileViewer from '~/components/Skills/display/SkillFileViewer';
import { CreateSkillForm, SkillForm } from '~/components/Skills/forms';
import { useHasAccess, useAuthContext, useLocalize } from '~/hooks';
import SkillDetail from '~/components/Skills/display/SkillDetail';
import SkillState from '~/components/Skills/display/SkillState';
import { CreateSkillForm, SkillForm } from '~/components/Skills/forms';
import OpenSidebar from '~/components/Chat/Menus/OpenSidebar';
import { useGetSkillByIdQuery } from '~/data-provider';
/**
* Skill detail / edit / create route content.
@@ -58,11 +59,14 @@ export default function SkillsView() {
// No skill selected — empty state
if (!skillId) {
return (
<div className="flex h-full w-full flex-col items-center justify-center bg-presentation">
<SkillState
title={localize('com_ui_skill_no_selection')}
description={localize('com_ui_skill_no_selection_desc')}
/>
<div className="flex h-full w-full flex-col bg-presentation">
<MobileSidebarToggle />
<div className="flex flex-1 flex-col items-center justify-center">
<SkillState
title={localize('com_ui_skill_no_selection')}
description={localize('com_ui_skill_no_selection_desc')}
/>
</div>
</div>
);
}
@@ -73,6 +77,7 @@ export default function SkillsView() {
function CreateView() {
return (
<div className="flex h-full w-full flex-col overflow-y-auto bg-presentation">
<MobileSidebarToggle />
<CreateSkillForm />
</div>
);
@@ -90,6 +95,7 @@ function DetailView({ skillId }: { skillId: string }) {
if (activeFile) {
return (
<div className="flex h-full w-full flex-col bg-presentation">
<MobileSidebarToggle />
<SkillFileViewer skillId={skillId} relativePath={activeFile} />
</div>
);
@@ -106,6 +112,7 @@ function DetailView({ skillId }: { skillId: string }) {
if (skillQuery.isError || !skillQuery.data) {
return (
<div className="flex h-full w-full flex-col bg-presentation">
<MobileSidebarToggle />
<SkillState
variant="error"
title={localize('com_ui_skill_not_found')}
@@ -117,6 +124,7 @@ function DetailView({ skillId }: { skillId: string }) {
return (
<div className="flex h-full w-full flex-col bg-presentation">
<MobileSidebarToggle />
<SkillDetail
skill={skillQuery.data}
onEdit={() => navigate(`/skills/${skillId}/edit`)}
@@ -130,7 +138,21 @@ function DetailView({ skillId }: { skillId: string }) {
function EditView({ skillId }: { skillId: string }) {
return (
<div className="flex h-full w-full flex-col overflow-y-auto bg-presentation">
<MobileSidebarToggle />
<SkillForm skillId={skillId} />
</div>
);
}
/** Sidebar reopen affordance for small screens, where the drawer is the only navigation. */
function MobileSidebarToggle() {
const isSmallScreen = useMediaQuery('(max-width: 768px)');
if (!isSmallScreen) {
return null;
}
return (
<div className="flex shrink-0 items-center px-4 pt-3">
<OpenSidebar />
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import SkillsView from '../SkillsView';
const mockUseHasAccess = jest.fn((..._args: unknown[]) => true);
const mockUseMediaQuery = jest.fn((_query: string) => false);
jest.mock(
'librechat-data-provider',
@@ -18,10 +19,16 @@ jest.mock(
'@librechat/client',
() => ({
Spinner: () => <div data-testid="spinner" />,
useMediaQuery: (query: string) => mockUseMediaQuery(query),
}),
{ virtual: true },
);
jest.mock('~/components/Chat/Menus/OpenSidebar', () => ({
__esModule: true,
default: () => <div data-testid="open-sidebar" />,
}));
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
useHasAccess: (...args: unknown[]) => mockUseHasAccess(...args),
@@ -58,6 +65,8 @@ describe('SkillsView', () => {
beforeEach(() => {
mockUseHasAccess.mockReset();
mockUseHasAccess.mockReturnValue(true);
mockUseMediaQuery.mockReset();
mockUseMediaQuery.mockReturnValue(false);
});
it('renders the create skill form for /skills/new', () => {
@@ -69,4 +78,25 @@ describe('SkillsView', () => {
expect(screen.getByTestId('create-skill-form')).toBeInTheDocument();
});
it('renders the sidebar toggle on small screens', () => {
mockUseMediaQuery.mockReturnValue(true);
const router = createMemoryRouter([{ path: '/skills', element: <SkillsView /> }], {
initialEntries: ['/skills'],
});
render(<RouterProvider router={router} />);
expect(screen.getByTestId('open-sidebar')).toBeInTheDocument();
});
it('does not render the sidebar toggle on large screens', () => {
const router = createMemoryRouter([{ path: '/skills', element: <SkillsView /> }], {
initialEntries: ['/skills'],
});
render(<RouterProvider router={router} />);
expect(screen.queryByTestId('open-sidebar')).not.toBeInTheDocument();
});
});