FN-052: remove Board mouse-drag panning

Remove scripted desktop Board panning while retaining native scrolling and mobile snapping.

- Remove the Board mouse-pan hook and its cursor styling.
- Update Board interaction tests and mobile coverage for browser-owned scrolling.
- Document the revised navigation behavior and publish a patch changeset.

Files changed:
 .changeset/fn-052-remove-board-mouse-pan.md        |   7 +
 docs/dashboard-guide.md                            |   4 +-
 packages/dashboard/app/components/Board.css        |  11 --
 packages/dashboard/app/components/Board.tsx        |  17 +--
 .../app/components/__tests__/Board.test.tsx        |  47 +++---
 .../app/components/__tests__/board-mobile.test.tsx |   8 +-
 .../app/hooks/__tests__/useBoardMousePan.test.tsx  | 164 ---------------------
 packages/dashboard/app/hooks/useBoardMousePan.ts   | 151 -------------------
 8 files changed, 39 insertions(+), 370 deletions(-)

Fusion-Task-Id: FN-052

Fusion-Task-Lineage: 7cd4c22f-09cf-4e09-8e24-729b5dd8d3fb

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-19 19:21:24 +00:00
parent 3fd4bdbc69
commit b533220716
8 changed files with 39 additions and 370 deletions

View File

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

View File

@@ -326,8 +326,8 @@ Features:
<!-- FNXC:TaskCardMovement 2026-08-19-18:52: Board and List task relocation is intentionally menu-only, so native card dragging cannot bypass the same explicit Move to choice on desktop, keyboard, and touch surfaces. -->
- 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.
<!-- FNXC:BoardNavigation 2026-08-18-18:18: Desktop operators can traverse the live workflow columns by dragging a safe Board surface horizontally; card drag-and-drop, card/control clicks, and mobile touch paging remain separate interactions. -->
- 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.
<!-- FNXC:BoardNavigation 2026-08-19-19:10: Board mouse press-drag must remain available for ordinary text selection and never script horizontal scrolling; desktop navigation stays browser-owned while phone touch retains its separate magnetic settle. -->
- 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

View File

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

View File

@@ -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<HTMLElement | null>(() => {
if (typeof document === "undefined") return null;
return document.getElementById("header-workflow-slot");
@@ -943,10 +940,9 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
<div className="board-workflow-view">
{renderedWorkflowToolbar}
<main
className={boardMousePanClassName}
className="board board-workflow-columns"
id="board"
ref={setBoardRef}
{...boardMousePanHandlers}
>
{aggregateRenderedBoardColumns.map((columnDef) => {
const isCreateColumn = aggregateQuickCreateTarget?.columnId === columnDef.id;
@@ -1040,10 +1036,9 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
<div className="board-workflow-view">
{renderedWorkflowToolbar}
<main
className={boardMousePanClassName}
className="board board-workflow-columns"
id="board"
ref={setBoardRef}
{...boardMousePanHandlers}
>
{selectedWorkflowColumns.map((columnDef) => {
const isCreateColumn = columnDef.id === selectedWorkflowCreateColumnId;

View File

@@ -117,6 +117,7 @@ vi.mock("../Column", () => ({
new-task-{column}
</button>
) : null}
{tasks.length === 0 ? <div className="empty-column">No tasks</div> : null}
{tasks.map((task) => (
<article key={task.id} data-id={task.id} data-testid={`board-task-card-${task.id}`}>
{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 {

View File

@@ -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 `<main className="board">` 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('<main className="board" id="board" ref={setBoardRef}>');
});
});

View File

@@ -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<HTMLElement | null>(null);
const { isPanning, ...bindings } = useBoardMousePan(boardElement);
return (
<main
ref={(element) => setBoardElement(element)}
className={isPanning ? "is-mouse-panning" : ""}
data-panning={isPanning ? "true" : "false"}
data-testid="board"
onClick={onClick}
{...bindings}
>
<button type="button" data-testid="button">Button</button>
<input aria-label="Editable" data-testid="input" />
<div data-id="FN-1" draggable="true" data-testid="card">Card</div>
<div data-testid="surface">Safe surface</div>
</main>
);
}
function renderPanHarness(onClick = vi.fn()) {
const result = render(<PanHarness onClick={onClick} />);
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);
});
});

View File

@@ -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<HTMLElement>) => void;
onPointerMove: (event: ReactPointerEvent<HTMLElement>) => void;
onPointerUp: (event: ReactPointerEvent<HTMLElement>) => void;
onPointerCancel: (event: ReactPointerEvent<HTMLElement>) => void;
onLostPointerCapture: (event: ReactPointerEvent<HTMLElement>) => void;
onClickCapture: (event: ReactMouseEvent<HTMLElement>) => 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<BoardMousePanSession | null>(null);
const didPanRef = useRef(false);
const [isPanning, setIsPanning] = useState(false);
const endSession = useCallback((event: ReactPointerEvent<HTMLElement>, 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<HTMLElement>) => {
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<HTMLElement>) => {
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<HTMLElement>) => {
endSession(event, false);
}, [endSession]);
const onPointerCancel = useCallback((event: ReactPointerEvent<HTMLElement>) => {
endSession(event, true);
}, [endSession]);
const onLostPointerCapture = useCallback((event: ReactPointerEvent<HTMLElement>) => {
endSession(event, true);
}, [endSession]);
const onClickCapture = useCallback((event: ReactMouseEvent<HTMLElement>) => {
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,
};
}