diff --git a/.changeset/fn-052-remove-board-mouse-pan.md b/.changeset/fn-052-remove-board-mouse-pan.md new file mode 100644 index 0000000000..ea4b68008b --- /dev/null +++ b/.changeset/fn-052-remove-board-mouse-pan.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Remove accidental Board mouse-drag panning while preserving mobile column snapping. +category: fix +dev: Desktop Board scrolling remains native; touch-only magnetic settling is unchanged. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index ae3d950708..e35ee072e1 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -326,8 +326,8 @@ Features: - Move a task from its context menu: right-click on desktop, use the overflow button or keyboard Context Menu/Shift+F10, or long-press on touch. When several legal destinations exist, open the grouped **Move to** submenu; a single destination remains a direct action. This changes task movement only—subtasks still use drag-to-reorder. - -- On desktop, click-drag an empty or otherwise safe Board surface to pan horizontally: drag right to reveal earlier columns and drag left to reveal later columns. Task cards and their controls remain reserved for clicks and context menus; phones continue to use native touch scrolling and column snapping. + +- On desktop, use native wheel, trackpad, scrollbar, and keyboard scrolling to traverse Board columns. Dragging Board text or whitespace does not pan the column strip. Phones continue to use native touch scrolling and mobile-only column snapping. - Search/filter tasks (including working-branch and base-branch dropdown filters with explicit **No working branch** / **No base branch** options) - Working-branch and base-branch filter selections are persisted per project and restored across refresh/navigation - Column visibility controls diff --git a/packages/dashboard/app/components/Board.css b/packages/dashboard/app/components/Board.css index db441a8580..aa8a71c027 100644 --- a/packages/dashboard/app/components/Board.css +++ b/packages/dashboard/app/components/Board.css @@ -44,17 +44,6 @@ The board needs a neutral first-paint shell whenever workflow lanes are enabled inline-size: 75%; } -/* -FNXC:BoardNavigation 2026-08-18-18:18: -A real desktop board pan gets non-semantic feedback on the existing scroll surface only. The active -state disables text selection while dragging, but does not add a control, change touch behavior, or -alter the desktop free-pan/mobile snap CSS boundary. -*/ -.board.board-workflow-columns.is-mouse-panning { - cursor: grabbing; - user-select: none; -} - @keyframes board-workflows-skeleton-pulse { 0%, 100% { opacity: 0.55; diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 1ed6c43b4f..368f870e85 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -11,7 +11,6 @@ import { createPortal } from "react-dom"; import { promoteTask, type ModelInfo, type BoardWorkflowsPayload, type BoardWorkflowColumn, type RevertTaskOptions, type RevertTaskResult } from "../api"; import { useBlockerFanout, type BlockerFanoutColumnFlags } from "../hooks/useBlockerFanout"; import { useColumnScrollSnap } from "../hooks/useColumnScrollSnap"; -import { useBoardMousePan } from "../hooks/useBoardMousePan"; import { MOBILE_MEDIA_QUERY, useViewportMode } from "../hooks/useViewportMode"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; import { WorkflowSwitcher } from "./WorkflowSwitcher"; @@ -227,13 +226,11 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o }, []); useColumnScrollSnap(boardElement, { mobileOnly: true }); /* - FNXC:BoardNavigation 2026-08-18-18:18: - Both live workflow Board variants share this callback-ref lifecycle so desktop mouse panning is - reachable on selected and All-workflows views without binding the loading skeleton or changing - the existing mobile-only column snap hook. + FNXC:BoardNavigation 2026-08-19-19:10: + Mouse press-drag must not pan the Board: it can begin text selection on empty-column content and + must leave desktop scrolling browser-owned. Both live workflow views retain this callback ref so + the mobile-only touch magnetism hook still attaches after either Board variant mounts. */ - const { isPanning: isBoardMousePanning, ...boardMousePanHandlers } = useBoardMousePan(boardElement); - const boardMousePanClassName = `board board-workflow-columns${isBoardMousePanning ? " is-mouse-panning" : ""}`; const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState(() => { if (typeof document === "undefined") return null; return document.getElementById("header-workflow-slot"); @@ -943,10 +940,9 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
{renderedWorkflowToolbar}
{aggregateRenderedBoardColumns.map((columnDef) => { const isCreateColumn = aggregateQuickCreateTarget?.columnId === columnDef.id; @@ -1040,10 +1036,9 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
{renderedWorkflowToolbar}
{selectedWorkflowColumns.map((columnDef) => { const isCreateColumn = columnDef.id === selectedWorkflowCreateColumnId; diff --git a/packages/dashboard/app/components/__tests__/Board.test.tsx b/packages/dashboard/app/components/__tests__/Board.test.tsx index 39ae4c5add..97373ab3ec 100644 --- a/packages/dashboard/app/components/__tests__/Board.test.tsx +++ b/packages/dashboard/app/components/__tests__/Board.test.tsx @@ -117,6 +117,7 @@ vi.mock("../Column", () => ({ new-task-{column} ) : null} + {tasks.length === 0 ?
No tasks
: null} {tasks.map((task) => (
{task.title ?? task.description ?? task.id} @@ -247,9 +248,12 @@ function makeBoardHorizontallyScrollable(board: HTMLElement, scrollLeft = 100) { board.scrollLeft = scrollLeft; } -function dragBoardSurface(board: HTMLElement, pointerId = 1, pointerType = "mouse") { - fireEvent.pointerDown(board, { button: 0, clientX: 100, clientY: 50, pointerId, pointerType }); - fireEvent.pointerMove(board, { clientX: 140, clientY: 50, pointerId, pointerType }); +function dispatchMouseDrag(target: HTMLElement, pointerId = 1) { + fireEvent.pointerDown(target, { button: 0, clientX: 100, clientY: 50, pointerId, pointerType: "mouse" }); + fireEvent.pointerMove(target, { clientX: 140, clientY: 50, pointerId, pointerType: "mouse" }); + // FNXC:BoardNavigation 2026-08-19-19:10: Continuing toward the edge covers the former scripted edgeward pan path. + fireEvent.pointerMove(target, { clientX: 190, clientY: 50, pointerId, pointerType: "mouse" }); + fireEvent.pointerUp(target, { pointerId, pointerType: "mouse" }); } function installMobileBoardStabilizationHarness() { @@ -2037,7 +2041,7 @@ describe("Board", () => { ]); }); - it("pans both selected and All-workflows boards through their live main surfaces", async () => { + it("does not pan selected or All-workflows Boards when dragging empty-column text or safe whitespace", async () => { enableFlag( { "FN-1": "builtin:coding", "FN-2": "wf-custom" }, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW], @@ -2046,21 +2050,22 @@ describe("Board", () => { const selectedBoard = screen.getByRole("main") as HTMLElement; makeBoardHorizontallyScrollable(selectedBoard); - dragBoardSurface(selectedBoard); - expect(selectedBoard.scrollLeft).toBe(60); - expect(selectedBoard).toHaveClass("is-mouse-panning"); - fireEvent.pointerUp(selectedBoard, { pointerId: 1, pointerType: "mouse" }); - expect(selectedBoard).not.toHaveClass("is-mouse-panning"); + dispatchMouseDrag(within(selectedBoard).getAllByText("No tasks")[0]); + expect(selectedBoard.scrollLeft).toBe(100); + expect(selectedBoard.className).toBe("board board-workflow-columns"); await selectWorkflow(ALL_WORKFLOWS_BOARD_VIEW_ID); const aggregateBoard = screen.getByRole("main") as HTMLElement; makeBoardHorizontallyScrollable(aggregateBoard); - dragBoardSurface(aggregateBoard, 2); - expect(aggregateBoard.scrollLeft).toBe(60); - fireEvent.pointerUp(aggregateBoard, { pointerId: 2, pointerType: "mouse" }); + dispatchMouseDrag(within(aggregateBoard).getAllByText("No tasks")[0], 2); + expect(aggregateBoard.scrollLeft).toBe(100); + // A populated Board's own safe whitespace must likewise remain browser-owned. + dispatchMouseDrag(aggregateBoard, 3); + expect(aggregateBoard.scrollLeft).toBe(100); + expect(aggregateBoard.className).toBe("board board-workflow-columns"); }); - it("keeps touch and task-card interactions outside desktop board panning", async () => { + it("keeps touch and task-card interactions native", async () => { const onQuickCreate = vi.fn().mockResolvedValue({}); enableFlag({ "FN-1": "builtin:coding" }); renderBoard({ tasks: [mkTask({ id: "FN-1" })], onQuickCreate }); @@ -2071,7 +2076,7 @@ describe("Board", () => { fireEvent.pointerMove(board, { clientX: 40, clientY: 50, pointerId: 1, pointerType: "touch" }); fireEvent.pointerUp(board, { pointerId: 1, pointerType: "touch" }); expect(board.scrollLeft).toBe(100); - expect(board).not.toHaveClass("is-mouse-panning"); + expect(board).toHaveClass("board", "board-workflow-columns"); const card = screen.getByTestId("board-task-card-FN-1"); /* @@ -2092,20 +2097,6 @@ describe("Board", () => { expect(onQuickCreate).toHaveBeenCalledTimes(1); }); - it("does not swallow a no-threshold board click", () => { - const onQuickCreate = vi.fn().mockResolvedValue({}); - enableFlag({}); - renderBoard({ onQuickCreate }); - const board = screen.getByRole("main") as HTMLElement; - makeBoardHorizontallyScrollable(board); - fireEvent.pointerDown(board, { button: 0, clientX: 100, clientY: 50, pointerId: 1, pointerType: "mouse" }); - fireEvent.pointerMove(board, { clientX: 102, clientY: 50, pointerId: 1, pointerType: "mouse" }); - fireEvent.pointerUp(board, { pointerId: 1, pointerType: "mouse" }); - fireEvent.click(screen.getByTestId("mock-quick-create-triage")); - expect(onQuickCreate).toHaveBeenCalledTimes(1); - expect(board).not.toHaveClass("is-mouse-panning"); - }); - it("preserves all-workflows board scroll during mobile visualViewport refresh stabilization", async () => { const harness = installMobileBoardStabilizationHarness(); try { diff --git a/packages/dashboard/app/components/__tests__/board-mobile.test.tsx b/packages/dashboard/app/components/__tests__/board-mobile.test.tsx index 2c6d33c440..7c8624f7e9 100644 --- a/packages/dashboard/app/components/__tests__/board-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/board-mobile.test.tsx @@ -103,10 +103,12 @@ describe("mobile board magnetic column snap wiring (FN-8235)", () => { expect(boardSource).toContain('import { useColumnScrollSnap } from "../hooks/useColumnScrollSnap";'); expect(boardSource).toContain("useColumnScrollSnap(boardElement, { mobileOnly: true });"); - expect(boardSource).toContain('const boardMousePanClassName = `board board-workflow-columns'); expect(boardSource.match(/ref=\{setBoardRef\}/g)).toHaveLength(2); - expect(boardSource.match(/className=\{boardMousePanClassName\}/g)).toHaveLength(2); - // The legacy `
` render is gone; assert it stays gone. + expect(boardSource.match(/className="board board-workflow-columns"/g)).toHaveLength(2); + expect(boardSource).not.toContain("useBoardMousePan"); + expect(boardSource).not.toContain("is-mouse-panning"); + expect(readAppFile("components/Board.css")).not.toContain(".is-mouse-panning"); + // FNXC:BoardNavigation 2026-08-19-19:10: The legacy live Board path stays removed; only the two stable workflow views mount the shared ref. expect(boardSource).not.toContain('
'); }); }); diff --git a/packages/dashboard/app/hooks/__tests__/useBoardMousePan.test.tsx b/packages/dashboard/app/hooks/__tests__/useBoardMousePan.test.tsx deleted file mode 100644 index f1686dd82a..0000000000 --- a/packages/dashboard/app/hooks/__tests__/useBoardMousePan.test.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { useState } from "react"; -import { fireEvent, render } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { useBoardMousePan } from "../useBoardMousePan"; - -function PanHarness({ onClick = vi.fn() }: { onClick?: () => void }) { - const [boardElement, setBoardElement] = useState(null); - const { isPanning, ...bindings } = useBoardMousePan(boardElement); - return ( -
setBoardElement(element)} - className={isPanning ? "is-mouse-panning" : ""} - data-panning={isPanning ? "true" : "false"} - data-testid="board" - onClick={onClick} - {...bindings} - > - - -
Card
-
Safe surface
-
- ); -} - -function renderPanHarness(onClick = vi.fn()) { - const result = render(); - const board = result.getByTestId("board"); - Object.defineProperty(board, "clientWidth", { configurable: true, value: 200 }); - Object.defineProperty(board, "scrollWidth", { configurable: true, value: 600 }); - return { ...result, board, safeSurface: result.getByTestId("surface") }; -} - -function pointerDown(target: HTMLElement, clientX = 100, clientY = 50, pointerId = 1, pointerType = "mouse") { - fireEvent.pointerDown(target, { button: 0, clientX, clientY, pointerId, pointerType }); -} - -function pointerMove(target: HTMLElement, clientX: number, clientY = 50, pointerId = 1, pointerType = "mouse") { - fireEvent.pointerMove(target, { clientX, clientY, pointerId, pointerType }); -} - -function pointerUp(target: HTMLElement, pointerId = 1, pointerType = "mouse") { - fireEvent.pointerUp(target, { button: 0, clientX: 100, clientY: 50, pointerId, pointerType }); -} - -describe("useBoardMousePan", () => { - it("pans horizontally by the inverse mouse delta in either direction", () => { - const { board, safeSurface } = renderPanHarness(); - board.scrollLeft = 100; - - pointerDown(safeSurface); - pointerMove(safeSurface, 140); - expect(board.scrollLeft).toBe(60); - expect(board).toHaveAttribute("data-panning", "true"); - - pointerUp(safeSurface); - board.scrollLeft = 100; - pointerDown(safeSurface, 100, 50, 2); - pointerMove(safeSurface, 70, 50, 2); - expect(board.scrollLeft).toBe(130); - }); - - it("keeps taps and non-overflow surfaces from becoming pans or consuming clicks", () => { - const onClick = vi.fn(); - const { board, safeSurface } = renderPanHarness(onClick); - - pointerDown(safeSurface); - pointerUp(safeSurface); - fireEvent.click(safeSurface); - expect(onClick).toHaveBeenCalledTimes(1); - expect(board).toHaveAttribute("data-panning", "false"); - - board.scrollLeft = 100; - Object.defineProperty(board, "scrollWidth", { configurable: true, value: 200 }); - pointerDown(safeSurface, 100, 50, 2); - pointerMove(safeSurface, 140, 50, 2); - pointerUp(safeSurface, 2); - fireEvent.click(safeSurface); - expect(board.scrollLeft).toBe(100); - expect(onClick).toHaveBeenCalledTimes(2); - }); - - it("ignores touch, pen, and non-primary mouse input", () => { - const onClick = vi.fn(); - const { board, safeSurface } = renderPanHarness(onClick); - board.scrollLeft = 100; - - for (const [pointerType, pointerId] of [["touch", 1], ["pen", 2]] as const) { - fireEvent.pointerDown(safeSurface, { button: 0, clientX: 100, clientY: 50, pointerId, pointerType }); - pointerMove(safeSurface, 40, 50, pointerId, pointerType); - pointerUp(safeSurface, pointerId, pointerType); - } - fireEvent.pointerDown(safeSurface, { button: 2, clientX: 100, clientY: 50, pointerId: 3, pointerType: "mouse" }); - pointerMove(safeSurface, 40, 50, 3); - pointerUp(safeSurface, 3); - - expect(board.scrollLeft).toBe(100); - expect(board).toHaveAttribute("data-panning", "false"); - fireEvent.click(safeSurface); - expect(onClick).toHaveBeenCalledTimes(1); - }); - - it("does not capture interactive, editable, or native-draggable card targets", () => { - const { board, getByTestId } = renderPanHarness(); - board.scrollLeft = 100; - - for (const target of [getByTestId("button"), getByTestId("input"), getByTestId("card")]) { - pointerDown(target); - pointerMove(target, 40); - pointerUp(target); - } - - expect(board.scrollLeft).toBe(100); - expect(board).toHaveAttribute("data-panning", "false"); - }); - - it("suppresses one compatibility click after a true pan, then allows later clicks", () => { - const onClick = vi.fn(); - const { safeSurface } = renderPanHarness(onClick); - - pointerDown(safeSurface); - pointerMove(safeSurface, 140); - pointerUp(safeSurface); - fireEvent.click(safeSurface); - fireEvent.click(safeSurface); - - expect(onClick).toHaveBeenCalledTimes(1); - }); - - it("ends panning on cancel and lost capture without leaving a stale click guard", () => { - const onClick = vi.fn(); - const { board, safeSurface } = renderPanHarness(onClick); - - pointerDown(safeSurface); - pointerMove(safeSurface, 140); - fireEvent.pointerCancel(safeSurface, { pointerId: 1 }); - expect(board).toHaveAttribute("data-panning", "false"); - fireEvent.click(safeSurface); - - pointerDown(safeSurface, 100, 50, 2); - pointerMove(safeSurface, 140, 50, 2); - fireEvent.lostPointerCapture(safeSurface, { pointerId: 2 }); - expect(board).toHaveAttribute("data-panning", "false"); - fireEvent.click(safeSurface); - - expect(onClick).toHaveBeenCalledTimes(2); - }); - - it("releases pointer capture during unmount cleanup", () => { - const { board, safeSurface, unmount } = renderPanHarness(); - const setPointerCapture = vi.fn(); - const hasPointerCapture = vi.fn(() => true); - const releasePointerCapture = vi.fn(); - Object.defineProperty(board, "setPointerCapture", { configurable: true, value: setPointerCapture }); - Object.defineProperty(board, "hasPointerCapture", { configurable: true, value: hasPointerCapture }); - Object.defineProperty(board, "releasePointerCapture", { configurable: true, value: releasePointerCapture }); - - pointerDown(safeSurface); - unmount(); - - expect(setPointerCapture).toHaveBeenCalledWith(1); - expect(releasePointerCapture).toHaveBeenCalledWith(1); - }); -}); diff --git a/packages/dashboard/app/hooks/useBoardMousePan.ts b/packages/dashboard/app/hooks/useBoardMousePan.ts deleted file mode 100644 index df50023fd0..0000000000 --- a/packages/dashboard/app/hooks/useBoardMousePan.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { - useCallback, - useEffect, - useRef, - useState, - type MouseEvent as ReactMouseEvent, - type PointerEvent as ReactPointerEvent, -} from "react"; - -const BOARD_MOUSE_PAN_THRESHOLD = 4; - -type BoardMousePanSession = { - element: HTMLElement; - pointerId: number; - startX: number; - startY: number; - startScrollLeft: number; - isPanning: boolean; -}; - -export interface BoardMousePanBindings { - isPanning: boolean; - onPointerDown: (event: ReactPointerEvent) => void; - onPointerMove: (event: ReactPointerEvent) => void; - onPointerUp: (event: ReactPointerEvent) => void; - onPointerCancel: (event: ReactPointerEvent) => void; - onLostPointerCapture: (event: ReactPointerEvent) => void; - onClickCapture: (event: ReactMouseEvent) => void; -} - -/* -FNXC:BoardNavigation 2026-08-18-18:18: -Desktop Board operators need a primary mouse click-drag on the existing scroll surface to reveal -workflow columns without Shift+Scroll. This seam owns only horizontal mouse panning; touch and pen -remain native mobile gestures, and task-card/native-draggable or interactive descendants keep their -existing click, context-menu, and drag behavior. -*/ -function isExcludedBoardPanTarget(target: EventTarget | null): boolean { - if (!(target instanceof Element)) return true; - return Boolean( - target.closest( - "button, a, input, textarea, select, option, label, summary, [contenteditable='true'], [draggable='true'], [data-id], [role='button'], [role='link'], [role='textbox'], [role='menuitem'], [role='checkbox'], [role='combobox'], [role='radio'], [role='slider'], [role='switch']", - ), - ); -} - -function releasePointerCapture(session: BoardMousePanSession): void { - const { element, pointerId } = session; - try { - if (element.hasPointerCapture?.(pointerId)) { - element.releasePointerCapture?.(pointerId); - } - } catch { - /* FNXC:BoardNavigation 2026-08-18-18:18: Browser teardown can release pointer capture before the hook cleanup runs; cleanup must remain idempotent. */ - } -} - -export function useBoardMousePan(boardElement: HTMLElement | null): BoardMousePanBindings { - const sessionRef = useRef(null); - const didPanRef = useRef(false); - const [isPanning, setIsPanning] = useState(false); - - const endSession = useCallback((event: ReactPointerEvent, clearClickGuard: boolean) => { - const session = sessionRef.current; - if (!session || session.pointerId !== event.pointerId) return; - releasePointerCapture(session); - sessionRef.current = null; - setIsPanning(false); - if (clearClickGuard) didPanRef.current = false; - }, []); - - const onPointerDown = useCallback((event: ReactPointerEvent) => { - if (event.pointerType !== "mouse" || event.button !== 0 || isExcludedBoardPanTarget(event.target)) { - return; - } - - const element = event.currentTarget; - didPanRef.current = false; - sessionRef.current = { - element, - pointerId: event.pointerId, - startX: event.clientX, - startY: event.clientY, - startScrollLeft: element.scrollLeft, - isPanning: false, - }; - element.setPointerCapture?.(event.pointerId); - }, []); - - const onPointerMove = useCallback((event: ReactPointerEvent) => { - const session = sessionRef.current; - if (!session || session.pointerId !== event.pointerId) return; - - const deltaX = event.clientX - session.startX; - const deltaY = event.clientY - session.startY; - if (!session.isPanning) { - const horizontalIntent = Math.abs(deltaX) > Math.abs(deltaY); - if ( - !horizontalIntent - || Math.abs(deltaX) < BOARD_MOUSE_PAN_THRESHOLD - || session.element.scrollWidth <= session.element.clientWidth - ) { - return; - } - session.isPanning = true; - didPanRef.current = true; - setIsPanning(true); - } - - event.preventDefault(); - session.element.scrollLeft = session.startScrollLeft - deltaX; - }, []); - - const onPointerUp = useCallback((event: ReactPointerEvent) => { - endSession(event, false); - }, [endSession]); - - const onPointerCancel = useCallback((event: ReactPointerEvent) => { - endSession(event, true); - }, [endSession]); - - const onLostPointerCapture = useCallback((event: ReactPointerEvent) => { - endSession(event, true); - }, [endSession]); - - const onClickCapture = useCallback((event: ReactMouseEvent) => { - if (!didPanRef.current) return; - didPanRef.current = false; - event.preventDefault(); - event.stopPropagation(); - }, []); - - useEffect(() => { - return () => { - const session = sessionRef.current; - if (session) releasePointerCapture(session); - sessionRef.current = null; - didPanRef.current = false; - }; - }, [boardElement]); - - return { - isPanning, - onPointerDown, - onPointerMove, - onPointerUp, - onPointerCancel, - onLostPointerCapture, - onClickCapture, - }; -}