From 98ca9ac0908fbcc9045f37e38ebdc6b22c2730f1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 1 Jul 2026 01:54:39 -0700 Subject: [PATCH] FN-7353: fix mobile task menu action taps Fix task context menu touch activation so mobile popup actions complete reliably. - Commit touch and pen menu actions on pointer release while preserving mouse click activation. - Guard synthesized mobile clicks so each selected task action runs exactly once and closes the menu. - Cover Board, List, shared context menu, and Task Detail mobile popup action selection in tests and docs. - Add a patch changeset for the published CLI bundle. Files changed: .changeset/fn-7353-mobile-popup-context-menu.md | 7 ++++ docs/dashboard-guide.md | 12 ++++--- .../dashboard/app/components/TaskContextMenu.tsx | 41 ++++++++++++++++++---- .../app/components/__tests__/ListView.test.tsx | 12 +++++-- .../app/components/__tests__/TaskCard.test.tsx | 26 +++++++++++--- .../components/__tests__/TaskContextMenu.test.tsx | 23 ++++++++++++ .../TaskDetailModal.definition-actions.test.tsx | 31 ++++++++++++++++ 7 files changed, 133 insertions(+), 19 deletions(-) Fusion-Task-Id: FN-7353 Fusion-Task-Lineage: 4de96306-1bcc-403d-88fa-be4ec0d9149d Co-authored-by: Fusion (runfusion.ai) --- .../fn-7353-mobile-popup-context-menu.md | 7 ++++ docs/dashboard-guide.md | 12 +++--- .../app/components/TaskContextMenu.tsx | 41 ++++++++++++++++--- .../components/__tests__/ListView.test.tsx | 12 ++++-- .../components/__tests__/TaskCard.test.tsx | 26 +++++++++--- .../__tests__/TaskContextMenu.test.tsx | 23 +++++++++++ ...askDetailModal.definition-actions.test.tsx | 31 ++++++++++++++ 7 files changed, 133 insertions(+), 19 deletions(-) create mode 100644 .changeset/fn-7353-mobile-popup-context-menu.md diff --git a/.changeset/fn-7353-mobile-popup-context-menu.md b/.changeset/fn-7353-mobile-popup-context-menu.md new file mode 100644 index 0000000000..11796aa66a --- /dev/null +++ b/.changeset/fn-7353-mobile-popup-context-menu.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix mobile task action menus so tapped actions run once and close. +category: fix +dev: Shared TaskContextMenu now commits touch/pen selections on pointer release and guards synthesized clicks. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index bc485653da..955ec1f8be 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -139,9 +139,10 @@ Features: - GitHub provenance marker on task cards imported from GitHub (`sourceType: github_import`), shown in the footer with other external-source metadata - Task card header meta badges group priority, fast mode, agent-created provenance, workflow identity, and elapsed/created-time chips into one wrapping row; agent labels prefer `sourceMetadata.agentName` over raw agent IDs - Task detail surfaces show the selected/effective workflow identity near the task's workflow controls so individual cards remain understandable when Board is in **All workflows** or another aggregate/mixed context. -- Board task cards support a context menu from right-click, keyboard context menu / Shift+F10, or touch long-press for detail-aligned lifecycle actions without changing normal card clicks. Completed card context menus include **Refine**, which opens the existing task-detail refinement feedback modal for the same task. +- Board task cards support a context menu from right-click, keyboard context menu / Shift+F10, or touch long-press for detail-aligned lifecycle actions without changing normal card clicks. Selecting an action applies that exact action once and dismisses the menu. Completed card context menus include **Refine**, which opens the existing task-detail refinement feedback modal for the same task. +FNXC:DoneTaskRefine 2026-07-01-00:00: Completed Board card context menus must label Refine only because they now route to the real task-detail refinement feedback modal instead of a dead row or direct API call. +FNXC:TaskContextMenu 2026-07-01-00:00: Board/List touch context-menu item taps must invoke the selected action exactly once and close the menu, matching desktop right-click and keyboard context-menu activation. --> @@ -175,10 +176,11 @@ Features: - Bulk selection + batch model updates - Bulk Pause / Unpause / Archive actions from the selection toolbar (`Pause selected`, `Unpause selected`, `Archive selected`) for fast batch task state management. - Bulk delete from the selection toolbar (`Delete selected`): archived selections are skipped automatically, and dependency-conflict failures can be force-deleted per task after a danger confirmation that removes dependency references. -- List rows and mobile cards support the same task context menu as Board cards from right-click, keyboard context menu / Shift+F10, or touch long-press without changing ordinary row selection or tap-to-open behavior, including **Refine** for completed tasks. +- List rows and mobile cards support the same task context menu as Board cards from right-click, keyboard context menu / Shift+F10, or touch long-press without changing ordinary row selection or tap-to-open behavior. Selecting an action applies that exact action once and dismisses the menu, including **Refine** for completed tasks. +FNXC:DoneTaskRefine 2026-07-01-00:00: Completed List row/card context menus route Refine to the existing task-detail feedback modal so desktop right-click and mobile long-press share the same refinement flow. +FNXC:TaskContextMenu 2026-07-01-00:00: Mobile List card long-press action taps must select and dismiss through the same shared TaskContextMenu invariant as Board and Task Detail surfaces. --> ![List view](./screenshots/list-view.png) @@ -1053,7 +1055,7 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou - These two metadata controls share matched sizing/alignment in read mode (including mobile wrapping) so they behave like a single polished control group. - Task metadata keeps priority, execution mode, provenance, optional workflow identity, optional PR context, and compact `Created` / `Updated` timestamps in one wrapping row across desktop and mobile widths; recent timestamps render as relative time (`just now`, `Xm`, `Xh`, `Xd`) and older values switch to short month/day dates. -- The **Actions** menu exposes **Pause** / **Unpause** for eligible non-terminal tasks, including tasks assigned to agents. If a task was paused by an agent, the **Paused by agent** note is informational; users can still unpause it manually from the same menu. +- The **Actions** menu exposes **Pause** / **Unpause** for eligible non-terminal tasks, including tasks assigned to agents. If a task was paused by an agent, the **Paused by agent** note is informational; users can still unpause it manually from the same menu. On mobile task popups, tapping an Actions item applies the selected action once and closes the menu. - After delete confirmations are complete, Task Detail closes immediately while the delete request finishes in the background; success and error outcomes still appear as toasts. - Eligible existing tasks (triage, todo, in-progress, in-review) expose a **GitHub tracking** section directly in Task Detail, even when tracking is currently disabled. - The GitHub tracking section now defaults to a compact summary row; use the disclosure arrow to expand linked-issue details plus tracking edit controls. diff --git a/packages/dashboard/app/components/TaskContextMenu.tsx b/packages/dashboard/app/components/TaskContextMenu.tsx index 3a2729aed5..795fabe874 100644 --- a/packages/dashboard/app/components/TaskContextMenu.tsx +++ b/packages/dashboard/app/components/TaskContextMenu.tsx @@ -1,6 +1,6 @@ import "./TaskContextMenu.css"; -import type { KeyboardEvent, ReactNode } from "react"; -import { Fragment, useEffect, useRef } from "react"; +import type { KeyboardEvent, PointerEvent as ReactPointerEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react"; +import { Fragment, useCallback, useEffect, useRef } from "react"; import type { TFunction } from "i18next"; import type { ColumnId, Task, TaskDetail } from "@fusion/core"; import { COLUMNS, VALID_TRANSITIONS, isColumn } from "@fusion/core"; @@ -288,6 +288,37 @@ export function TaskContextMenu({ autoFocusFirstItem = true, }: TaskContextMenuProps) { const menuRef = useRef(null); + const touchSelectedActionRef = useRef<{ id: string; at: number } | null>(null); + + const selectAction = useCallback((action: TaskMenuActionDescriptor) => { + if (action.disabled || action.tone === "note" || !action.onSelect) return; + onActionSelect?.(action); + action.onSelect(); + }, [onActionSelect]); + + /* + FNXC:TaskContextMenu 2026-07-01-00:00: + Mobile task menus must commit the selected action on touch/pen pointer release before host popovers can be removed by outside-click or focus retargeting. Desktop mouse keeps click activation, while the click guard prevents synthesized mobile clicks from firing the same task action twice. + */ + const handleActionPointerUp = useCallback((event: ReactPointerEvent, action: TaskMenuActionDescriptor) => { + if (event.pointerType === "mouse") return; + event.preventDefault(); + event.stopPropagation(); + touchSelectedActionRef.current = { id: action.id, at: Date.now() }; + selectAction(action); + }, [selectAction]); + + const handleActionClick = useCallback((event: ReactMouseEvent, action: TaskMenuActionDescriptor) => { + const touchSelection = touchSelectedActionRef.current; + if (touchSelection?.id === action.id && Date.now() - touchSelection.at < 1000) { + event.preventDefault(); + event.stopPropagation(); + touchSelectedActionRef.current = null; + return; + } + touchSelectedActionRef.current = null; + selectAction(action); + }, [selectAction]); useEffect(() => { if (!autoFocusFirstItem) return; @@ -330,10 +361,8 @@ export function TaskContextMenu({ className={classes.join(" ")} role={role === "menu" ? "menuitem" : undefined} disabled={action.disabled} - onClick={() => { - onActionSelect?.(action); - action.onSelect?.(); - }} + onPointerUp={(event) => handleActionPointerUp(event, action)} + onClick={(event) => handleActionClick(event, action)} > {action.label} diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index 7e750661d8..6e61827369 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -801,7 +801,7 @@ describe("ListView", () => { viewportSpy.mockRestore(); }); - it("opens the task context menu from mobile card long-press without ordinary tap-to-open", () => { + it("opens the task context menu from mobile card long-press, selects the tapped action, and suppresses ordinary tap-to-open", async () => { vi.useFakeTimers(); const viewportSpy = mockMobileViewport(); const onOpenDetail = vi.fn(); @@ -822,8 +822,13 @@ describe("ListView", () => { expect(screen.getByRole("menuitem", { name: "Pause" })).toBeInTheDocument(); expect(onOpenDetail).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole("menuitem", { name: "Pause" })); + fireEvent.pointerUp(screen.getByRole("menuitem", { name: "Pause" }), { pointerType: "touch", pointerId: 2 }); + await act(async () => { + await Promise.resolve(); + }); expect(onPauseTask).toHaveBeenCalledWith("FN-001"); + expect(onPauseTask).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); viewportSpy.mockRestore(); vi.useRealTimers(); }); @@ -841,9 +846,10 @@ describe("ListView", () => { act(() => { vi.advanceTimersByTime(550); }); - fireEvent.click(screen.getByRole("menuitem", { name: "Refine" })); + fireEvent.pointerUp(screen.getByRole("menuitem", { name: "Refine" }), { pointerType: "touch", pointerId: 2 }); expect(onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-011" }), { origin: "list-mobile", initialAction: "refine" }); + expect(onOpenDetail).toHaveBeenCalledTimes(1); expect(screen.queryByRole("menu")).not.toBeInTheDocument(); viewportSpy.mockRestore(); vi.useRealTimers(); diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index 699d99dc81..aff5dd16c8 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -210,17 +210,20 @@ describe("TaskCard", () => { fireEvent.click(screen.getByRole("menuitem", { name: "Pause" })); await waitFor(() => expect(onPauseTask).toHaveBeenCalledWith("FN-001")); + expect(onPauseTask).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); expect(onOpenDetail).not.toHaveBeenCalled(); }); - it("opens the board card context menu from keyboard without opening detail", () => { + it("opens the board card context menu from keyboard, selects an action, and closes", async () => { const onOpenDetail = vi.fn(); + const onArchiveTask = vi.fn(async () => makeTask({ column: "archived" })); render( , ); @@ -229,7 +232,11 @@ describe("TaskCard", () => { fireEvent.keyDown(card, { key: "F10", shiftKey: true }); expect(screen.getByRole("menu")).toBeInTheDocument(); - expect(screen.getByRole("menuitem", { name: "Archive" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("menuitem", { name: "Archive" })); + + await waitFor(() => expect(onArchiveTask).toHaveBeenCalledWith("FN-001")); + expect(onArchiveTask).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); expect(onOpenDetail).not.toHaveBeenCalled(); }); @@ -357,15 +364,16 @@ describe("TaskCard", () => { expect(screen.queryByRole("menuitem", { name: "Merge & Close" })).not.toBeInTheDocument(); }); - it("opens the board card context menu on touch long-press and suppresses detail click", () => { + it("opens the board card context menu on touch long-press, selects the tapped action, and suppresses detail click", async () => { vi.useFakeTimers(); const onOpenDetail = vi.fn(); + const onUnpauseTask = vi.fn(async () => makeTask()); render( makeTask())} + onUnpauseTask={onUnpauseTask} />, ); @@ -377,6 +385,14 @@ describe("TaskCard", () => { fireEvent.pointerUp(card, { pointerType: "touch", pointerId: 1, clientX: 16, clientY: 16 }); fireEvent.click(card); expect(onOpenDetail).not.toHaveBeenCalled(); + + fireEvent.pointerUp(screen.getByRole("menuitem", { name: "Unpause" }), { pointerType: "touch", pointerId: 2 }); + await act(async () => { + await Promise.resolve(); + }); + expect(onUnpauseTask).toHaveBeenCalledWith("FN-001"); + expect(onUnpauseTask).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); }); it("cancels board card long-press when touch moves before the delay", () => { diff --git a/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx b/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx index cc09cd959b..f8ebad8c3e 100644 --- a/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskContextMenu.test.tsx @@ -182,6 +182,29 @@ describe("TaskContextMenu shared task action model", () => { expect(onDelete).toHaveBeenCalledTimes(1); }); + it("selects enabled touch menu items on pointer release exactly once", () => { + const onPause = vi.fn(); + const onActionSelect = vi.fn(); + render( + , + ); + + fireEvent.pointerUp(screen.getByRole("menuitem", { name: "Pause" }), { pointerType: "touch", pointerId: 1 }); + + expect(onActionSelect).toHaveBeenCalledTimes(1); + expect(onActionSelect).toHaveBeenCalledWith(expect.objectContaining({ id: "pause" })); + expect(onPause).toHaveBeenCalledTimes(1); + expect(screen.getByRole("menuitem", { name: "Disabled" })).toBeDisabled(); + expect(screen.getByText("Paused by agent")).toHaveAttribute("role", "note"); + }); + it("focuses the first enabled action and supports arrow-key roving", () => { render( { }); + it("mobile task popup Actions menu selects a tapped item once and dismisses", async () => { + const { pauseTask } = await import("../../api"); + const mockPauseTask = vi.mocked(pauseTask); + mockPauseTask.mockResolvedValueOnce(makeTask({ id: "FN-001", paused: true }) as Task); + const addToast = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /actions/i })); + const pauseItem = screen.getByRole("menuitem", { name: "Pause" }); + + fireEvent.pointerUp(pauseItem, { pointerType: "touch", pointerId: 1 }); + + await waitFor(() => expect(mockPauseTask).toHaveBeenCalledWith("FN-001", undefined)); + expect(mockPauseTask).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + expect(addToast).toHaveBeenCalledWith("Paused FN-001", "success"); + }); + it("successful duplicate shows success toast with new task ID", async () => { mockConfirm.mockResolvedValue(true);