diff --git a/client/src/components/Nav/Favorites/FavoritesList.tsx b/client/src/components/Nav/Favorites/FavoritesList.tsx index af008595fa..6798e76ee9 100644 --- a/client/src/components/Nav/Favorites/FavoritesList.tsx +++ b/client/src/components/Nav/Favorites/FavoritesList.tsx @@ -2,9 +2,9 @@ import React, { useRef, useCallback, useMemo, useEffect, memo } from 'react'; import { useRecoilValue } from 'recoil'; import { LayoutGrid } from 'lucide-react'; import { useDrag, useDrop } from 'react-dnd'; -import { Skeleton } from '@librechat/client'; import { useNavigate } from 'react-router-dom'; import { useQueries } from '@tanstack/react-query'; +import { Skeleton, useMediaQuery } from '@librechat/client'; import { QueryKeys, EModelEndpoint, dataService } from 'librechat-data-provider'; import type { Agent, TEndpointsConfig, TModelSpec } from 'librechat-data-provider'; import { @@ -63,6 +63,13 @@ const DraggableFavoriteItem = ({ children, }: DraggableFavoriteItemProps) => { const ref = useRef(null); + /** + * HTML5 drag needs a hover-capable pointer. Connecting the drag source on touch would + * stamp `draggable="true"` on this wrapper, and iOS Safari hands a touch on a draggable + * element to the drag recognizer instead of synthesizing a click, so the row underneath + * only selects on the second tap. + */ + const canDrag = useMediaQuery('(hover: hover)'); const [{ handlerId }, drop] = useDrop<{ index: number; id: string }, unknown, { handlerId: any }>( { accept: 'favorite-item', @@ -118,7 +125,8 @@ const DraggableFavoriteItem = ({ }); const opacity = isDragging ? 0 : 1; - drag(drop(ref)); + drop(ref); + drag(canDrag ? ref : null); return (
diff --git a/client/src/components/Nav/Favorites/tests/FavoritesList.spec.tsx b/client/src/components/Nav/Favorites/tests/FavoritesList.spec.tsx index 6d44086cf4..2f1ccab12a 100644 --- a/client/src/components/Nav/Favorites/tests/FavoritesList.spec.tsx +++ b/client/src/components/Nav/Favorites/tests/FavoritesList.spec.tsx @@ -526,4 +526,44 @@ describe('FavoritesList', () => { expect(types).toEqual(['agent', 'model', 'spec']); }); }); + + describe('drag source by pointer capability', () => { + const mockHover = (hasHover: boolean) => { + (window.matchMedia as jest.Mock).mockImplementation((query: string) => ({ + matches: query === '(hover: hover)' ? hasHover : false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); + }; + + const renderFavoriteWrapper = async () => { + mockFavorites.push({ model: 'gpt-5', endpoint: 'openai' }); + const { findByTestId } = renderWithProviders(); + const item = await findByTestId('favorite-item'); + return item.closest('[data-handler-id]'); + }; + + it('connects the drag source on a hover-capable pointer', async () => { + mockHover(true); + + const wrapper = await renderFavoriteWrapper(); + + await waitFor(() => expect(wrapper).toHaveAttribute('draggable', 'true')); + }); + + it('leaves the row undraggable on touch so the first tap selects it', async () => { + mockHover(false); + + const wrapper = await renderFavoriteWrapper(); + + /** iOS Safari gives a touch on a draggable element to the drag recognizer + * instead of synthesizing a click, costing the row its first tap. */ + expect(wrapper).not.toHaveAttribute('draggable', 'true'); + }); + }); });