diff --git a/.changeset/fn-7362-board-context-menu-overlay.md b/.changeset/fn-7362-board-context-menu-overlay.md
new file mode 100644
index 0000000000..f3e7e4643e
--- /dev/null
+++ b/.changeset/fn-7362-board-context-menu-overlay.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Fix Board task context menus so they are not clipped by columns.
+category: fix
+dev: Board TaskCard menus are portaled to document.body and clamped in viewport coordinates.
diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index 955ec1f8be..2a42e0aa25 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -139,10 +139,11 @@ 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. 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.
+- 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. The menu opens as an independent overlay so it stays visible beyond the card or column edge while remaining clamped to the viewport. 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: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:TaskContextMenu 2026-07-01-00:00: Board card context menus must behave like independent overlays because Board columns intentionally clip and scroll their bodies for kanban containment. -->
diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css
index 9d00af6718..14c01eea58 100644
--- a/packages/dashboard/app/components/TaskCard.css
+++ b/packages/dashboard/app/components/TaskCard.css
@@ -38,10 +38,8 @@
}
.task-card-context-menu-popover {
- position: absolute;
- left: var(--task-card-context-menu-x);
- top: var(--task-card-context-menu-y);
- z-index: 30;
+ position: fixed;
+ z-index: 1000;
}
.task-card-context-menu-popover .task-context-menu {
diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx
index 947e2cf3ff..fe54cd2826 100644
--- a/packages/dashboard/app/components/TaskCard.tsx
+++ b/packages/dashboard/app/components/TaskCard.tsx
@@ -2,6 +2,7 @@ import "./TaskCard.css";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { memo, useCallback, useState, useRef, useEffect, useLayoutEffect, useMemo, type CSSProperties, type ReactElement } from "react";
+import { createPortal } from "react-dom";
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest, AlertTriangle, ArrowUpRight } from "lucide-react";
import type { Task, TaskDetail, Column, ColumnId, PrInfo, IssueInfo, TaskPriority, GithubIssueAction, MergeResult } from "@fusion/core";
import {
@@ -1972,6 +1973,10 @@ function TaskCardComponent({
setContextMenuPosition(null);
}, []);
+ useEffect(() => {
+ closeContextMenu();
+ }, [closeContextMenu, task.column, task.id]);
+
const clearLongPressTimer = useCallback(() => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
@@ -1982,12 +1987,10 @@ function TaskCardComponent({
const openContextMenuAt = useCallback((clientX: number, clientY: number) => {
if (!hasContextMenuActions || isEditing) return;
- const rect = cardRef.current?.getBoundingClientRect();
- if (!rect) return;
setShowSendBackMenu(false);
setContextMenuPosition({
- x: Math.max(CONTEXT_MENU_VIEWPORT_MARGIN, Math.min(clientX - rect.left, rect.width - CONTEXT_MENU_VIEWPORT_MARGIN)),
- y: Math.max(CONTEXT_MENU_VIEWPORT_MARGIN, Math.min(clientY - rect.top, rect.height - CONTEXT_MENU_VIEWPORT_MARGIN)),
+ x: Math.max(CONTEXT_MENU_VIEWPORT_MARGIN, Math.min(clientX, window.innerWidth - CONTEXT_MENU_VIEWPORT_MARGIN)),
+ y: Math.max(CONTEXT_MENU_VIEWPORT_MARGIN, Math.min(clientY, window.innerHeight - CONTEXT_MENU_VIEWPORT_MARGIN)),
});
}, [hasContextMenuActions, isEditing]);
@@ -2039,27 +2042,17 @@ function TaskCardComponent({
}, [clearLongPressTimer]);
/*
- FNXC:TaskContextMenu 2026-06-30-00:15:
- Board card context menus open from pointer and keyboard coordinates, so clamp after render using the measured menu size. This keeps long action lists inside the viewport without changing normal card click or drag behavior.
+ FNXC:TaskContextMenu 2026-07-01-00:00:
+ Board columns intentionally clip and scroll their bodies, so card context menus must be portaled to document.body and positioned in viewport coordinates. Clamp after render using the measured menu size so right-click, keyboard, and long-press menus escape column borders without weakening board overflow containment.
*/
useLayoutEffect(() => {
if (!contextMenuPosition) return;
const menu = contextMenuRef.current;
- const card = cardRef.current;
- if (!menu || !card) return;
+ if (!menu) return;
const menuRect = menu.getBoundingClientRect();
- const cardRect = card.getBoundingClientRect();
- const maxX = Math.max(
- CONTEXT_MENU_VIEWPORT_MARGIN,
- Math.min(cardRect.width - CONTEXT_MENU_VIEWPORT_MARGIN, window.innerWidth - cardRect.left - menuRect.width - CONTEXT_MENU_VIEWPORT_MARGIN),
- );
- const maxY = Math.max(
- CONTEXT_MENU_VIEWPORT_MARGIN,
- Math.min(cardRect.height - CONTEXT_MENU_VIEWPORT_MARGIN, window.innerHeight - cardRect.top - menuRect.height - CONTEXT_MENU_VIEWPORT_MARGIN),
- );
const nextPosition = {
- x: Math.max(CONTEXT_MENU_VIEWPORT_MARGIN, Math.min(contextMenuPosition.x, maxX)),
- y: Math.max(CONTEXT_MENU_VIEWPORT_MARGIN, Math.min(contextMenuPosition.y, maxY)),
+ x: Math.max(CONTEXT_MENU_VIEWPORT_MARGIN, Math.min(contextMenuPosition.x, window.innerWidth - menuRect.width - CONTEXT_MENU_VIEWPORT_MARGIN)),
+ y: Math.max(CONTEXT_MENU_VIEWPORT_MARGIN, Math.min(contextMenuPosition.y, window.innerHeight - menuRect.height - CONTEXT_MENU_VIEWPORT_MARGIN)),
};
if (nextPosition.x !== contextMenuPosition.x || nextPosition.y !== contextMenuPosition.y) {
setContextMenuPosition(nextPosition);
@@ -2352,11 +2345,11 @@ function TaskCardComponent({
tabIndex={hasContextMenuActions ? 0 : undefined}
aria-haspopup={hasContextMenuActions ? "menu" : undefined}
>
- {contextMenuPosition && hasContextMenuActions && (
+ {contextMenuPosition && hasContextMenuActions && createPortal(
event.stopPropagation()}
onContextMenu={(event) => event.preventDefault()}
>
@@ -2364,7 +2357,8 @@ function TaskCardComponent({
actions={contextMenuActions}
onActionSelect={closeContextMenu}
/>
-
+ ,
+ document.body,
)}
{task.id}
diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
index aff5dd16c8..38cd237020 100644
--- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
@@ -150,6 +150,42 @@ function expectTimerInFooterRight(container: HTMLElement) {
expect(timer?.closest(".card-meta-badges")).toBeNull();
}
+function mockBoardContextMenuGeometry() {
+ const originalInnerWidth = window.innerWidth;
+ const originalInnerHeight = window.innerHeight;
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 800 });
+ Object.defineProperty(window, "innerHeight", { configurable: true, value: 600 });
+ const rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function getMockRect(this: HTMLElement) {
+ if (this.classList.contains("card")) {
+ return { x: 520, y: 340, left: 520, top: 340, right: 760, bottom: 520, width: 240, height: 180, toJSON: () => ({}) } as DOMRect;
+ }
+ if (this.classList.contains("task-card-context-menu-popover")) {
+ const left = Number.parseFloat(this.style.left || "0");
+ const top = Number.parseFloat(this.style.top || "0");
+ return { x: left, y: top, left, top, right: left + 180, bottom: top + 220, width: 180, height: 220, toJSON: () => ({}) } as DOMRect;
+ }
+ return { x: 0, y: 0, left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON: () => ({}) } as DOMRect;
+ });
+ return () => {
+ rectSpy.mockRestore();
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth });
+ Object.defineProperty(window, "innerHeight", { configurable: true, value: originalInnerHeight });
+ };
+}
+
+function expectBoardContextMenuPortaled() {
+ const menu = screen.getByRole("menu");
+ const popover = menu.closest(".task-card-context-menu-popover") as HTMLElement | null;
+ expect(popover).not.toBeNull();
+ expect(popover?.parentElement).toBe(document.body);
+ expect(popover?.closest(".card")).toBeNull();
+ expect(popover?.closest(".column")).toBeNull();
+ expect(popover?.closest(".column-body")).toBeNull();
+ expect(popover?.style.left).not.toBe("");
+ expect(popover?.style.top).not.toBe("");
+ return popover!;
+}
+
const highFanout = {
totalCount: 7,
activeTodoCount: 3,
@@ -192,52 +228,73 @@ describe("TaskCard", () => {
expect(onOpenDetailWithTab.mock.calls[0][1]).toBe("workflow");
});
- it("opens the board card context menu on right-click without opening detail", async () => {
+ it("opens the board card context menu as a viewport portal on right-click without opening detail", async () => {
+ const cleanupGeometry = mockBoardContextMenuGeometry();
const onOpenDetail = vi.fn();
const onPauseTask = vi.fn(async () => makeTask({ paused: true }));
- render(
-
,
- );
+ try {
+ render(
+
,
+ );
- fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
- expect(screen.getByRole("menu")).toBeInTheDocument();
- expect(onOpenDetail).not.toHaveBeenCalled();
+ fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 790, clientY: 590 });
+ const popover = await waitFor(() => expectBoardContextMenuPortaled());
+ expect(popover.style.left).toBe("612px");
+ expect(popover.style.top).toBe("372px");
+ expect(onOpenDetail).not.toHaveBeenCalled();
- 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();
+ 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(document.querySelector(".task-card-context-menu-popover")).toBeNull();
+ expect(onOpenDetail).not.toHaveBeenCalled();
+ } finally {
+ cleanupGeometry();
+ }
});
- it("opens the board card context menu from keyboard, selects an action, and closes", async () => {
+ it("opens the board card context menu from keyboard as a viewport portal, selects an action, and closes", async () => {
+ const cleanupGeometry = mockBoardContextMenuGeometry();
const onOpenDetail = vi.fn();
const onArchiveTask = vi.fn(async () => makeTask({ column: "archived" }));
- render(
-
,
- );
+ try {
+ render(
+
,
+ );
- const card = document.querySelector(".card") as HTMLElement;
- card.focus();
- fireEvent.keyDown(card, { key: "F10", shiftKey: true });
+ const card = document.querySelector(".card") as HTMLElement;
+ card.focus();
+ fireEvent.keyDown(card, { key: "F10", shiftKey: true });
- expect(screen.getByRole("menu")).toBeInTheDocument();
- fireEvent.click(screen.getByRole("menuitem", { name: "Archive" }));
+ expectBoardContextMenuPortaled();
+ 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();
+ await waitFor(() => expect(onArchiveTask).toHaveBeenCalledWith("FN-001"));
+ expect(onArchiveTask).toHaveBeenCalledTimes(1);
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
+ expect(onOpenDetail).not.toHaveBeenCalled();
+ } finally {
+ cleanupGeometry();
+ }
});
it("shows refine for a done card context menu and routes to the refinement opener", () => {
@@ -364,35 +421,44 @@ describe("TaskCard", () => {
expect(screen.queryByRole("menuitem", { name: "Merge & Close" })).not.toBeInTheDocument();
});
- it("opens the board card context menu on touch long-press, selects the tapped action, and suppresses detail click", async () => {
+ it("opens the board card context menu on touch long-press as a viewport portal, selects the tapped action, and suppresses detail click", async () => {
vi.useFakeTimers();
+ const cleanupGeometry = mockBoardContextMenuGeometry();
const onOpenDetail = vi.fn();
const onUnpauseTask = vi.fn(async () => makeTask());
- render(
-
,
- );
+ try {
+ render(
+
,
+ );
- const card = document.querySelector(".card") as HTMLElement;
- fireEvent.pointerDown(card, { pointerType: "touch", pointerId: 1, clientX: 16, clientY: 16 });
- act(() => vi.advanceTimersByTime(550));
+ const card = document.querySelector(".card") as HTMLElement;
+ fireEvent.pointerDown(card, { pointerType: "touch", pointerId: 1, clientX: 790, clientY: 590 });
+ act(() => vi.advanceTimersByTime(550));
- expect(screen.getByRole("menu")).toBeInTheDocument();
- fireEvent.pointerUp(card, { pointerType: "touch", pointerId: 1, clientX: 16, clientY: 16 });
- fireEvent.click(card);
- expect(onOpenDetail).not.toHaveBeenCalled();
+ expectBoardContextMenuPortaled();
+ fireEvent.pointerUp(card, { pointerType: "touch", pointerId: 1, clientX: 790, clientY: 590 });
+ 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();
+ 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();
+ } finally {
+ cleanupGeometry();
+ }
});
it("cancels board card long-press when touch moves before the delay", () => {
@@ -414,6 +480,64 @@ describe("TaskCard", () => {
expect(screen.queryByRole("menu")).toBeNull();
});
+ it("dispatches portaled board menu actions for the interacted duplicate card", async () => {
+ mockConfirm.mockResolvedValueOnce(true);
+ const onDuplicateTask = vi.fn(async () => makeTask({ id: "FN-002-copy" }));
+ render(
+
,
+ );
+
+ const secondCard = document.querySelectorAll(".card")[1] as HTMLElement;
+ fireEvent.contextMenu(secondCard, { clientX: 64, clientY: 72 });
+ expectBoardContextMenuPortaled();
+ fireEvent.click(screen.getByRole("menuitem", { name: "Duplicate" }));
+
+ await waitFor(() => expect(onDuplicateTask).toHaveBeenCalledWith("FN-002"));
+ expect(onDuplicateTask).toHaveBeenCalledTimes(1);
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
+ });
+
+ it("cleans up the portaled board menu when the task column changes", () => {
+ const { rerender } = render(
+
,
+ );
+
+ fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
+ expectBoardContextMenuPortaled();
+
+ rerender(
+
,
+ );
+
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
+ expect(document.querySelector(".task-card-context-menu-popover")).toBeNull();
+ });
+
it("does not show the Answer-questions button when not awaiting input", () => {
render(