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:
7
.changeset/fn-7353-mobile-popup-context-menu.md
Normal file
7
.changeset/fn-7353-mobile-popup-context-menu.md
Normal 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.
|
||||
@@ -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. -->
|
||||
|
||||

|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user