diff --git a/.changeset/fn-009-board-mouse-pan.md b/.changeset/fn-009-board-mouse-pan.md new file mode 100644 index 0000000000..d34f03e07e --- /dev/null +++ b/.changeset/fn-009-board-mouse-pan.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index a25a046d5a..bbe856e063 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -294,6 +294,8 @@ Board view is the kanban surface for day-to-day operation. Features: - Drag-and-drop between lifecycle columns + +- 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 diff --git a/packages/dashboard/app/components/Board.css b/packages/dashboard/app/components/Board.css index aa8a71c027..db441a8580 100644 --- a/packages/dashboard/app/components/Board.css +++ b/packages/dashboard/app/components/Board.css @@ -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; diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 4558000f87..f81db38c7c 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -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(() => { 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 (
{renderedWorkflowToolbar} -
+
{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
{renderedWorkflowToolbar}
{ const id = (e.target as HTMLElement)?.closest?.("[data-id]")?.getAttribute("data-id"); if (id) draggingTaskIdRef.current = id; diff --git a/packages/dashboard/app/components/__tests__/Board.test.tsx b/packages/dashboard/app/components/__tests__/Board.test.tsx index dd61fee028..83a4037bae 100644 --- a/packages/dashboard/app/components/__tests__/Board.test.tsx +++ b/packages/dashboard/app/components/__tests__/Board.test.tsx @@ -118,7 +118,7 @@ vi.mock("../Column", () => ({ ) : null} {tasks.map((task) => ( -
+
{task.title ?? task.description ?? task.id}
))} @@ -241,6 +241,17 @@ function renderBoard(props = {}) { return render(); } +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 { diff --git a/packages/dashboard/app/hooks/__tests__/useBoardMousePan.test.tsx b/packages/dashboard/app/hooks/__tests__/useBoardMousePan.test.tsx new file mode 100644 index 0000000000..f1686dd82a --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useBoardMousePan.test.tsx @@ -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(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 new file mode 100644 index 0000000000..df50023fd0 --- /dev/null +++ b/packages/dashboard/app/hooks/useBoardMousePan.ts @@ -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) => 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, + }; +}