fix: reliable mobile board horizontal swipe over task cards

Swiping across the board to scroll horizontally was intermittent when the
gesture started on a task card. Cards render with native HTML5 `draggable`
for desktop drag-to-move, but native DnD does not function via touch — the
attribute only arms the browser's touch-drag heuristic, which non-
deterministically hijacks horizontal swipes meant to pan the board.

Disable native drag on touch-primary devices (`(hover: none) and
(pointer: coarse)`) via a new `useCoarsePointer` hook, folded into the
card's `isDraggable`. Mouse/desktop and hybrid laptops keep drag-to-move;
touch loses nothing (DnD never worked there) and panning is now reliable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-13 19:34:22 -07:00
parent 417183da9f
commit 0d75725a28
4 changed files with 71 additions and 1 deletions

View File

@@ -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.

View File

@@ -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;

View File

@@ -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(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />);
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(
<TaskCard

View File

@@ -0,0 +1,32 @@
import { useEffect, useState } from "react";
// Touch-primary devices (phones/tablets, Android WebViews). A hybrid laptop
// with a trackpad/mouse reports `(hover: hover)` and is intentionally excluded
// so mouse drag-to-move keeps working there.
export const COARSE_POINTER_MEDIA_QUERY = "(hover: none) and (pointer: coarse)";
export function isCoarsePointer(): boolean {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
return window.matchMedia(COARSE_POINTER_MEDIA_QUERY).matches;
}
// Whether the primary pointer is coarse (touch). Native HTML5 drag-and-drop is
// non-functional via touch, yet a `draggable` element still arms the browser's
// touch-drag heuristic and can hijack a horizontal swipe meant to scroll the
// board. Components use this to drop `draggable` on touch so panning is reliable.
export function useCoarsePointer(): boolean {
const [coarse, setCoarse] = useState<boolean>(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;
}