📱 fix: Don't Connect the Favorites Drag Source on Touch Pointers (#14312)

#14272 gated the hover-revealed "..." button on hover capability, but pinned
agents still take two taps on iOS. That fix was aimed at the wrong mechanism
for this list.

Every favorite row is wrapped by DraggableFavoriteItem, and react-dnd's
HTML5Backend stamps `draggable="true"` on that wrapper unconditionally
(connectDragSource, HTML5BackendImpl.js:101 — `canDrag: false` does not
suppress it, react-dnd#2909). iOS Safari hands a touch on a draggable element
to the drag recognizer rather than synthesizing a click, so the row underneath
only selects on the second tap.

The draggable wrapper is what separates favorites from every other sidebar
row. Conversation rows are more hover-dependent than favorites ever were
(ungated `opacity-0 group-hover:opacity-100` plus an onMouseEnter that mounts
ConvoOptions) and select on the first tap.

Connect the drag source only under `(hover: hover)`. Nothing is lost on touch:
HTML5Backend has no touch support, so drag-to-reorder never worked there.
Passing null to the connector unsubscribes cleanly and resets the attribute,
so a hybrid pointer flipping the query re-arms drag.
This commit is contained in:
Danny Avila 2026-07-16 11:29:07 -04:00 committed by GitHub
parent bd1df30b7d
commit b04ff2648e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 50 additions and 2 deletions

View file

@ -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<HTMLDivElement>(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 (
<div ref={ref} style={{ opacity }} data-handler-id={handlerId}>

View file

@ -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(<FavoritesList />);
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');
});
});
});