FN-009: add desktop Board mouse-drag navigation

Add safe desktop click-drag panning to the Board while preserving existing card and touch interactions.

- Add the mouse-pan hook with pointer capture, intent threshold, and click suppression.
- Wire panning and cursor states into Board surfaces with regression coverage.
- Document the interaction and publish the feature changeset.

Files changed:
 .changeset/fn-009-board-mouse-pan.md               |   7 +
 docs/dashboard-guide.md                            |   2 +
 packages/dashboard/app/components/Board.css        |  11 ++
 packages/dashboard/app/components/Board.tsx        |  19 ++-
 .../app/components/__tests__/Board.test.tsx        |  78 +++++++++-
 .../app/hooks/__tests__/useBoardMousePan.test.tsx  | 164 +++++++++++++++++++++
 packages/dashboard/app/hooks/useBoardMousePan.ts   | 151 +++++++++++++++++++
 7 files changed, 429 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-009

Fusion-Task-Lineage: 0f0bee56-cbb8-4e8d-b866-51d439ab9655

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-18 19:08:55 +00:00
parent c30d65080e
commit e0ffd31e83
7 changed files with 429 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add desktop click-drag panning across Board workflow columns.
category: feature
dev: Safe Board surfaces pan horizontally with the native scroll position; task-card drag-and-drop and mobile touch paging remain unchanged.

View File

@@ -294,6 +294,8 @@ Board view is the kanban surface for day-to-day operation.
Features:
- Drag-and-drop between lifecycle columns
<!-- 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 native drag-and-drop, clicks, and context menus; phones continue to use native touch scrolling and 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,6 +44,17 @@ 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

@@ -9,6 +9,7 @@ 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 { getBoardCanDropTaskRejection } from "./boardCanDropTask";
@@ -199,6 +200,14 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
setBoardElement((current) => current === element ? current : element);
}, []);
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.
*/
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");
@@ -955,7 +964,12 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
return (
<div className="board-workflow-view">
{renderedWorkflowToolbar}
<main className="board board-workflow-columns" id="board" ref={setBoardRef}>
<main
className={boardMousePanClassName}
id="board"
ref={setBoardRef}
{...boardMousePanHandlers}
>
{aggregateRenderedBoardColumns.map((columnDef) => {
const isCreateColumn = aggregateQuickCreateTarget?.columnId === columnDef.id;
const isDoneLikeColumn = columnDef.flags.complete === true && columnDef.flags.archived !== true;
@@ -1043,9 +1057,10 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
<div className="board-workflow-view">
{renderedWorkflowToolbar}
<main
className="board board-workflow-columns"
className={boardMousePanClassName}
id="board"
ref={setBoardRef}
{...boardMousePanHandlers}
onDragStart={(e) => {
const id = (e.target as HTMLElement)?.closest?.("[data-id]")?.getAttribute("data-id");
if (id) draggingTaskIdRef.current = id;

View File

@@ -118,7 +118,7 @@ vi.mock("../Column", () => ({
</button>
) : null}
{tasks.map((task) => (
<article key={task.id} data-testid={`board-task-card-${task.id}`}>
<article key={task.id} data-id={task.id} draggable data-testid={`board-task-card-${task.id}`}>
{task.title ?? task.description ?? task.id}
</article>
))}
@@ -241,6 +241,17 @@ function renderBoard(props = {}) {
return render(<Board {...createBoardProps(props)} />);
}
function makeBoardHorizontallyScrollable(board: HTMLElement, scrollLeft = 100) {
Object.defineProperty(board, "clientWidth", { configurable: true, value: 200 });
Object.defineProperty(board, "scrollWidth", { configurable: true, value: 600 });
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 installMobileBoardStabilizationHarness() {
const originalMatchMedia = window.matchMedia;
const originalRequestAnimationFrame = window.requestAnimationFrame;
@@ -1988,6 +1999,71 @@ describe("Board", () => {
]);
});
it("pans both selected and All-workflows boards through their live main surfaces", async () => {
enableFlag(
{ "FN-1": "builtin:coding", "FN-2": "wf-custom" },
[DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
);
renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "intake" })] });
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");
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" });
});
it("keeps touch and task-card interactions outside desktop board panning", async () => {
const onQuickCreate = vi.fn().mockResolvedValue({});
enableFlag({ "FN-1": "builtin:coding" });
renderBoard({ tasks: [mkTask({ id: "FN-1" })], onQuickCreate });
const board = screen.getByRole("main") as HTMLElement;
makeBoardHorizontallyScrollable(board);
fireEvent.pointerDown(board, { button: 0, clientX: 100, clientY: 50, pointerId: 1, pointerType: "touch" });
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");
const card = screen.getByTestId("board-task-card-FN-1");
expect(card).toHaveAttribute("draggable", "true");
fireEvent.pointerDown(card, { button: 0, clientX: 100, clientY: 50, pointerId: 2, pointerType: "mouse" });
fireEvent.pointerMove(card, { clientX: 40, clientY: 50, pointerId: 2, pointerType: "mouse" });
fireEvent.pointerUp(card, { pointerId: 2, pointerType: "mouse" });
expect(board.scrollLeft).toBe(100);
const quickCreate = screen.getByTestId("mock-quick-create-triage");
fireEvent.pointerDown(quickCreate, { button: 0, clientX: 100, clientY: 50, pointerId: 3, pointerType: "mouse" });
fireEvent.pointerMove(quickCreate, { clientX: 40, clientY: 50, pointerId: 3, pointerType: "mouse" });
fireEvent.pointerUp(quickCreate, { pointerId: 3, pointerType: "mouse" });
fireEvent.click(quickCreate);
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

@@ -0,0 +1,164 @@
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

@@ -0,0 +1,151 @@
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,
};
}