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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 01:54:39 -07:00
parent 62c4aae038
commit 98ca9ac090
7 changed files with 133 additions and 19 deletions

View File

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

View File

@@ -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:BoardCardActions 2026-06-29-00:00: Board card context menus are documented as alternate entry points only; normal click still opens task detail, and mobile long-press must not trigger detail behind the menu.
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: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. -->
<!-- FNXC:WorkflowBadges 2026-06-30-09:10: Task cards and task detail need workflow-name badges wherever mixed-workflow board contexts can hide the selected lane, especially the Board-only All workflows aggregate. -->
<!-- FNXC:BoardDoneSorting 2026-06-29-00:00: The Done board column exposes a local descending sort selector so operators can review either latest completions or highest task IDs without changing other lifecycle columns. -->
<!-- FNXC:BoardDoneSorting 2026-06-29-20:28: Document both Done sort modes as descending-only and Done-column-only so legacy Done and workflow complete-lane operators understand the selector does not change other lifecycle columns. -->
@@ -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:ListContextMenu 2026-06-29-00:00: List context menus are alternate action entry points only; desktop left-click still selects the split-pane detail and mobile tap still opens detail while long-press suppresses the follow-up tap.
FNXC:ListContextMenu 2026-06-30-00:20: Keyboard access is part of the Board/List context-menu contract, so docs must include the context-menu key and Shift+F10 alongside pointer and touch entry points.
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: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.
<!-- FNXC:TaskDetailWorkflowBadge 2026-06-29-18:45: Task Detail header metadata shows the resolved workflow name when board-workflows metadata is available, but omits the chip entirely for missing or stale workflow payloads so embedded, modal, and mobile headers do not render empty badge shells. -->
- 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.

View File

@@ -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<HTMLDivElement | null>(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<HTMLButtonElement>, 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<HTMLButtonElement>, 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}
</button>

View File

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

View File

@@ -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(
<TaskCard
task={makeTask({ column: "done", status: "done" as any })}
onOpenDetail={onOpenDetail}
addToast={noop}
onArchiveTask={vi.fn()}
onArchiveTask={onArchiveTask}
/>,
);
@@ -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(
<TaskCard
task={makeTask({ paused: true, userPaused: true })}
onOpenDetail={onOpenDetail}
addToast={noop}
onUnpauseTask={vi.fn(async () => 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", () => {

View File

@@ -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(
<TaskContextMenu
actions={[
{ id: "pause", label: "Pause", onSelect: onPause },
{ id: "disabled", label: "Disabled", disabled: true, onSelect: vi.fn() },
{ id: "note", label: "Paused by agent", tone: "note", disabled: true, onSelect: vi.fn() },
]}
onActionSelect={onActionSelect}
/>,
);
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(
<TaskContextMenu

View File

@@ -800,6 +800,37 @@ describe("TaskDetailModal", () => {
});
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(
<TaskDetailContent
task={makeTask({ id: "FN-001", column: "todo", paused: false, userPaused: false })}
initialTab="definition"
embedded
onRequestClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
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);