FN-8178: fix task-card context menu autofocus dismissal

Prevent portaled task-card menus from closing when autofocus creates a scroll event.

- Focus the first menu action with preventScroll while preserving keyboard access.
- Cover all card menu entry points and intentional dismissal paths.
- Document the interaction lifecycle fix and publish a patch changeset.

Files changed:
 .changeset/fn-8178-context-menu-flash-dismiss.md   |  7 +++
 docs/dashboard-guide.md                            |  2 +-
 .../task-card-context-menu-flash-dismiss-layers.md | 35 +++++++++++
 .../dashboard/app/components/TaskContextMenu.tsx   |  9 ++-
 .../app/components/__tests__/TaskCard.test.tsx     | 69 ++++++++++++++++++++++
 5 files changed, 120 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-8178

Fusion-Task-Lineage: 2eb94ffd-e922-497d-8904-1aea8bf760b1

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 21:13:30 -07:00
parent e72629c251
commit bc7dfe4bbf
5 changed files with 120 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep task-card action menus open and usable after they receive keyboard focus.
category: fix
dev: Prevents portal menu autofocus from triggering the board-scroll dismissal listener.

View File

@@ -243,7 +243,7 @@ FNXC:PlannerOversight 2026-07-05-00:00: FN-7604 removed that desktop-inline bran
<!-- FNXC:PlannerOversight 2026-07-04-18:00: FN-7519 adds a read-only Intervention Timeline. FN-7571 (2026-07-04-19:00) relocates it from an inline mount below the FN-7517 controls into the task-detail Activity view dropdown as a fourth "Interventions" segment, alongside Live/Feed/Raw. -->
- The task detail modal's **Activity** tab view dropdown (Live/Feed/Raw) gains a fourth **Interventions** option, shown only when planner oversight is active for the task (same gate as the former inline mount: `(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff`). Selecting it renders the **Intervention Timeline** (`data-testid="planner-intervention-timeline"`) inside the Activity panel, listing every recorded planner-overseer intervention for the task, newest-first: watched stage, reason, action taken, outcome (with a `.status-dot` indicator using semantic outcome tokens), an attempt count/limit badge (only when both are present), and source links (agent log / review comment / failed check / merge error / PR state / generic URL). It renders a calm "No planner interventions yet" empty state rather than an empty shell when there are none. When oversight is off or unresolved, the Interventions option is absent from the dropdown entirely (no leftover empty segment), and if it was previously selected the view falls back to Live rather than leaving a blank panel. Entries are read via `GET /tasks/:id/overseer/interventions`, which assembles them from the existing run-audit store under the `overseer:intervention` mutation type (`recordPlannerIntervention`/`getPlannerInterventionTimeline` in `@fusion/core`). This is a pure read surface — FN-7520 wires the actual intervention-producing call-sites.
- 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, the visible ⋯ button, 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. On mobile, long-press opens that menu without selecting card text or showing native copy/paste callouts. Selecting an action applies that exact action once and dismisses the menu. Cards that are still in Planning/ideas/hold columns include **Plan**, which opens Planning Mode seeded from the card and creates a new planned task when completed; completed card context menus include **Refine**, which opens the existing task-detail refinement feedback modal for the same task and keeps it open until the operator uses an explicit close path or an enabled backdrop dismissal.
- Board task cards support a context menu from right-click, keyboard context menu / Shift+F10, the visible ⋯ button, 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. It stays open until an action is selected, the operator clicks outside, presses Escape, or intentionally scrolls the board. On mobile, long-press opens that menu without selecting card text or showing native copy/paste callouts. Selecting an action applies that exact action once and dismisses the menu. Cards that are still in Planning/ideas/hold columns include **Plan**, which opens Planning Mode seeded from the card and creates a new planned task when completed; completed card context menus include **Refine**, which opens the existing task-detail refinement feedback modal for the same task and keeps it open until the operator uses an explicit close path or an enabled backdrop dismissal.
<!-- 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:TaskDetailRefine 2026-07-12-00:00: The Refine feedback modal must not be dismissed by the same mouse/touch interaction that opened it; backdrop dismissal follows the global default-off modal-dismiss preference.
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.

View File

@@ -0,0 +1,35 @@
---
title: "Task-card popup menus dismiss after autofocus scroll"
date: 2026-07-16
category: ui-bugs
module: packages/dashboard/app/components/TaskContextMenu
problem_type: interaction_lifecycle_race
component: dashboard-task-card
tags:
- task-card
- context-menu
- portal
- focus
- scroll
- fn-8178
---
# Task-card popup menus dismiss after autofocus scroll
## Problem
A task-card context menu could flash open and immediately dismiss, leaving lifecycle actions unusable. The menu is portaled to `document.body`, but Board card and ListView hosts intentionally listen for capture-phase `scroll` and close an open menu when the operator scrolls.
## Root cause
`TaskContextMenu` automatically focused its first enabled action after mount. In browsers, focusing that fixed, portaled action can scroll a scrollable board ancestor into view. The resulting scroll reached the host's capture-phase dismissal listener, which correctly treated ordinary board scrolling as an explicit dismissal but could not distinguish focus-created scrolling. The result was an open → autofocus → scroll → close loop.
This was not a card stacking-context or outside-pointer containment failure: the popover is a `document.body` portal and its ref correctly recognizes presses inside the menu. All card entry methods converge on the same mounted `TaskContextMenu`, so ⋯ click, right-click, touch/pen long-press, and keyboard context-menu access shared the fault.
## Resolution
Keep automatic keyboard focus, but call `focus({ preventScroll: true })` for the first enabled menu item. This prevents the synthetic scroll while preserving intentional dismissal on item selection, outside pointerdown, Escape, and real board scrolling. Because `TaskContextMenu` is shared, ListView and Task Detail also retain their focus behavior without duplicated lifecycle guards.
## Regression coverage
The TaskCard suite simulates a browser that dispatches a scroll after default focus and verifies that each card entry method remains open with `preventScroll`, invokes its action, and still honors outside-click, Escape, and real-scroll dismissal.

View File

@@ -411,10 +411,17 @@ export function TaskContextMenu({
selectAction(action);
}, [selectAction]);
/*
FNXC:TaskContextMenu 2026-07-16-20:50 (FN-8178):
Menus are portaled while their TaskCard/ListView hosts close on capture-phase board scroll. Focusing
the first action must not scroll a board ancestor, because that focus-created scroll is not an
explicit dismissal and previously closed the menu immediately. Preserve keyboard focus while
`preventScroll` leaves real user scrolling available to close the menu.
*/
useEffect(() => {
if (!autoFocusFirstItem) return;
const firstItem = menuRef.current?.querySelector<HTMLButtonElement>("button:not(:disabled)");
firstItem?.focus();
firstItem?.focus({ preventScroll: true });
}, [actions, autoFocusFirstItem]);
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {

View File

@@ -532,6 +532,75 @@ describe("TaskCard", () => {
}
});
/*
FNXC:TaskContextMenu 2026-07-16-20:50 (FN-8178):
All card entry points converge on the same portaled auto-focus lifecycle. Simulate a browser that
scrolls a focused portal unless `preventScroll` is requested; that scroll previously reached the
capture-phase closer and made every card action unusable.
*/
it("keeps every card menu entry point open when autofocus would otherwise scroll", async () => {
vi.useFakeTimers();
const cleanupGeometry = mockBoardContextMenuGeometry();
const onPauseTask = vi.fn(async () => makeTask({ paused: true }));
const nativeFocus = HTMLElement.prototype.focus;
const focusSpy = vi.spyOn(HTMLElement.prototype, "focus").mockImplementation(function focusWithBrowserScroll(options?: FocusOptions) {
nativeFocus.call(this);
if (!options?.preventScroll) {
window.setTimeout(() => window.dispatchEvent(new Event("scroll")), 0);
}
});
const openMethods = [
() => fireEvent.click(screen.getByTestId("card-menu-btn-FN-001")),
() => fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }),
() => {
fireEvent.pointerDown(document.querySelector(".card")!, { pointerType: "touch", pointerId: 7, clientX: 24, clientY: 28 });
act(() => vi.advanceTimersByTime(550));
},
() => fireEvent.keyDown(document.querySelector(".card")!, { key: "ContextMenu" }),
];
try {
render(
<TaskCard
task={makeTask({ column: "in-progress", status: "executing" as any })}
onOpenDetail={noop}
addToast={noop}
onPauseTask={onPauseTask}
/>,
);
for (const openMenu of openMethods) {
openMenu();
act(() => vi.runOnlyPendingTimers());
expectBoardContextMenuPortaled();
expect(focusSpy).toHaveBeenLastCalledWith({ preventScroll: true });
const pauseItem = screen.getByRole("menuitem", { name: "Pause" });
fireEvent.pointerUp(pauseItem, { pointerType: "touch", pointerId: 8 });
await act(async () => {
await Promise.resolve();
});
expect(onPauseTask).toHaveBeenLastCalledWith("FN-001");
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
}
// Explicit dismissals remain intentional after focus no longer creates a scroll dismissal.
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
expectBoardContextMenuPortaled();
fireEvent.pointerDown(document.body);
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
act(() => window.dispatchEvent(new Event("scroll")));
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
} finally {
focusSpy.mockRestore();
cleanupGeometry();
}
});
it("opens Planning Mode from eligible pre-execution card menus only when wired", async () => {
const cleanupGeometry = mockBoardContextMenuGeometry();
const onPlanningMode = vi.fn();