diff --git a/.changeset/fix-mobile-card-swipe-scroll.md b/.changeset/fix-mobile-card-swipe-scroll.md new file mode 100644 index 0000000000..7295ef65e6 --- /dev/null +++ b/.changeset/fix-mobile-card-swipe-scroll.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fixed unreliable horizontal scrolling when swiping across task cards on the mobile board. Native HTML5 drag is now disabled on touch-primary devices (where it never worked anyway), so the browser no longer hijacks swipe-to-scroll gestures that start on a card. diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index b45013dea9..b90fafcd2d 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -18,6 +18,7 @@ import { PrCreateModal } from "./PrCreateModal"; import { ProviderIcon } from "./ProviderIcon"; import { PluginSlot } from "./PluginSlot"; import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket"; +import { useCoarsePointer } from "../hooks/useCoarsePointer"; import { getFreshBatchData } from "../hooks/useBatchBadgeFetch"; import { useTaskDiffStats } from "../hooks/useTaskDiffStats"; import { useAgentsMapCache } from "../hooks/useAgentsMapCache"; @@ -983,7 +984,12 @@ function TaskCardComponent({ const isAwaitingInput = task.status === "awaiting-user-input"; const isArchived = task.column === "archived"; const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && !isStuck && !isAwaitingApproval && !isAwaitingInput && (task.column === "in-progress" || ACTIVE_STATUSES.has(visualStatus as string)); - const isDraggable = !disableDrag && !queued && !isPaused && !isEditing && !isArchived; // Disable drag during edit/archived or host embedding + // Native HTML5 drag is desktop-mouse only — it doesn't move cards via touch. + // On touch-primary devices the `draggable` attribute still arms the browser's + // touch-drag heuristic, which intermittently hijacks horizontal swipes meant + // to scroll the board. Drop drag on coarse pointers so panning stays reliable. + const isCoarsePointer = useCoarsePointer(); + const isDraggable = !disableDrag && !queued && !isPaused && !isEditing && !isArchived && !isCoarsePointer; // Disable drag during edit/archived, host embedding, or touch // Check if this card can be edited inline const canEdit = EDITABLE_COLUMNS.has(task.column) && !isAgentActive && !isPaused && !queued && onUpdateTask; diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index 75e346be9b..6f39cd31f4 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -607,6 +607,33 @@ describe("TaskCard", () => { expect(card.getAttribute("draggable")).toBe("false"); }); + // FN-6389 follow-up: native HTML5 drag is desktop-mouse only and doesn't move + // cards via touch, but a `draggable` element still arms the browser's touch-drag + // heuristic, which intermittently hijacks horizontal swipes meant to scroll the + // mobile board. On touch-primary (coarse pointer) devices we drop `draggable`. + it("disables native card dragging on touch-primary (coarse pointer) devices", () => { + const original = window.matchMedia; + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(hover: none) and (pointer: coarse)", + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as unknown as typeof window.matchMedia; + try { + const { container } = render(); + const card = container.querySelector(".card") as HTMLElement; + expect(card.getAttribute("draggable")).toBe("false"); + // No drag-start handler should be wired on touch (would arm the heuristic). + const dragStart = new Event("dragstart", { bubbles: true, cancelable: true }); + const prevented = !card.dispatchEvent(dragStart); + expect(prevented).toBe(false); + } finally { + window.matchMedia = original; + } + }); + it("renders Nx PR badge label when multiple PRs are linked", () => { render( (isCoarsePointer); + + useEffect(() => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return; + + const query = window.matchMedia(COARSE_POINTER_MEDIA_QUERY); + const update = () => setCoarse(query.matches); + + update(); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, []); + + return coarse; +}