FN-7927: fix Refine feedback modal self-dismissing immediately after opening

The task-detail Refine overlay used a raw onClick backdrop handler with a stopPropagation-wrapped inner modal, so the same click/touch sequence that opened Refine could bubble into the backdrop handler and close it right away; route it through the shared useOverlayDismiss contract instead so it behaves like every other dashboard modal.

- Compute refineOverlayDismissProps via useOverlayDismiss(handleCloseRefineModal) and spread it onto the refine overlay instead of a plain onClick handler
- Drop the redundant stopPropagation-only onClick from the inner .detail-refine-modal div now that the overlay itself no longer misfires on the opening interaction
- Update docs/dashboard-guide.md to document that the Refine modal (Board and List entry points) stays open until an explicit close or an enabled backdrop dismissal
- Add TaskDetailModal.refine.test.tsx regression coverage for the modal staying open across the opening interaction and honoring the dismiss-preference gate
- Add a patch changeset summarizing the fix for @runfusion/fusion release notes

Files changed:
 .changeset/fn-7927-refine-modal.md                 |   7 +
 docs/dashboard-guide.md                            |   8 +-
 .../dashboard/app/components/TaskDetailModal.tsx   |  12 +-
 .../__tests__/TaskDetailModal.refine.test.tsx      | 182 +++++++++++++++++++++
 4 files changed, 201 insertions(+), 8 deletions(-)

Fusion-Task-Id: FN-7927

Fusion-Task-Lineage: c7e7cd4a-d103-47e6-93ce-6577147b4795

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 23:37:54 -07:00
parent f7e942e6f4
commit 30d2e3660d
4 changed files with 201 additions and 8 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep the task refinement feedback dialog open after selecting Refine.
category: fix
dev: Routes the nested Task Detail refine overlay through useOverlayDismiss and adds regression coverage.

View File

@@ -227,8 +227,9 @@ 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, 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. 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. 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. 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.
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.
@@ -274,8 +275,9 @@ Features:
- 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.
- Desktop List view keeps the two-pane table/detail split. Tablet-width and mobile viewports use the single-pane card layout so list controls and quick-add stay full-width; tapping a task opens detail instead of selecting an embedded split pane.
- List rows and tablet/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:ListView 2026-07-10-00:00: FN-7809 makes tablet List view use the same single-pane card/detail route as mobile because the desktop split sidebar clipped the primary controls and quick-add composer at 769–1024px.
- List rows and tablet/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; the opened refinement feedback modal stays open until the operator closes it intentionally.
<!-- FNXC:TaskDetailRefine 2026-07-12-00:00: List-originated Refine uses the same task-detail feedback modal as Board and must preserve the stay-open invariant across desktop and mobile activation.
FNXC:ListView 2026-07-10-00:00: FN-7809 makes tablet List view use the same single-pane card/detail route as mobile because the desktop split sidebar clipped the primary controls and quick-add composer at 769–1024px.
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.

View File

@@ -2801,6 +2801,11 @@ export function TaskDetailContent({
setRefineFeedback("");
setIsRefining(false);
}, []);
/*
FNXC:TaskDetailRefine 2026-07-12-00:00:
The nested refine overlay must use the shared overlay-dismiss contract so the click/touch sequence that opens Refine never self-dismisses the freshly mounted composer, and so backdrop presses honor the global default-off modal-dismiss preference like every other dashboard modal.
*/
const refineOverlayDismissProps = useOverlayDismiss(handleCloseRefineModal);
const handleSubmitRefine = useCallback(async () => {
if (!refineFeedback.trim()) {
@@ -5888,14 +5893,11 @@ export function TaskDetailContent({
{showRefineModal && (
<div
className="modal-overlay open detail-refine-overlay"
onClick={handleCloseRefineModal}
{...refineOverlayDismissProps}
role="dialog"
aria-modal="true"
>
<div
className="modal detail-refine-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="modal detail-refine-modal">
<div className="modal-header">
<h3 className="detail-refine-title">{t("taskDetail.refine.modalTitle", "Refine")}</h3>
<button className="modal-close" onClick={handleCloseRefineModal} aria-label={t("common.close", "Close")}>

View File

@@ -0,0 +1,182 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
makeTask,
noop,
noopDelete,
noopMerge,
noopMove,
noopOpenDetail,
setupTaskDetailModalHooks,
} from "./TaskDetailModal.test-helpers";
import { TaskDetailModal } from "../TaskDetailModal";
import { ModalDismissPreferenceProvider } from "../../hooks/useOverlayDismiss";
import { refineTask } from "../../api";
setupTaskDetailModalHooks();
const renderDoneTaskDetail = (options: {
column?: "done" | "in-review";
initialAction?: { action: "refine"; requestId: number };
addToast?: (message: string, type?: any) => void;
onClose?: () => void;
dismissPreferenceEnabled?: boolean;
} = {}) => {
const modal = (
<TaskDetailModal
task={makeTask({ id: "FN-001", column: options.column ?? "done", status: options.column === "in-review" ? "review" as any : "done" as any })}
initialTab="definition"
initialAction={options.initialAction}
onClose={options.onClose ?? noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={options.addToast ?? noop}
/>
);
return render(
options.dismissPreferenceEnabled === undefined
? modal
: <ModalDismissPreferenceProvider enabled={options.dismissPreferenceEnabled}>{modal}</ModalDismissPreferenceProvider>,
);
};
const openRefineFromActionsMenu = () => {
fireEvent.click(screen.getByRole("button", { name: "Actions" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Refine" }));
};
const openRefineFromActionsMenuTouch = () => {
fireEvent.click(screen.getByRole("button", { name: "Actions" }));
const refineItem = screen.getByRole("menuitem", { name: "Refine" });
fireEvent.pointerUp(refineItem, { pointerType: "touch" });
};
const expectRefineComposerOpen = () => {
expect(screen.getByText("Refine", { selector: "h3" })).toBeInTheDocument();
expect(screen.getByPlaceholderText("Enter your feedback here...")).toBeInTheDocument();
};
const refineOverlay = () => {
const overlay = document.querySelector(".detail-refine-overlay");
expect(overlay).toBeInstanceOf(HTMLElement);
return overlay as HTMLElement;
};
/*
FNXC:TaskDetailRefine 2026-07-12-00:00:
The refine dialog must stay open across desktop menu clicks, mobile pointer activation, initialAction deep links, and Android compatibility mouse events; only explicit controls, Escape, or preference-enabled real backdrop presses may close it.
*/
describe("TaskDetailModal refine modal dismissal invariant", () => {
beforeEach(() => {
vi.mocked(refineTask).mockClear();
vi.mocked(refineTask).mockResolvedValue({ id: "FN-002", column: "todo" } as any);
});
it("keeps the in-modal actions-menu refine dialog open through the desktop opening click", () => {
renderDoneTaskDetail({ column: "done" });
openRefineFromActionsMenu();
expectRefineComposerOpen();
});
it("keeps the in-review refine dialog open through mobile touch activation and Android compatibility mouse events", () => {
renderDoneTaskDetail({ column: "in-review" });
openRefineFromActionsMenuTouch();
expectRefineComposerOpen();
const overlay = refineOverlay();
fireEvent.touchEnd(document);
fireEvent.mouseDown(overlay);
fireEvent.mouseUp(overlay);
fireEvent.click(overlay);
expectRefineComposerOpen();
});
it("keeps the initialAction refine dialog open through Android compatibility mouse events after touchend", () => {
renderDoneTaskDetail({ initialAction: { action: "refine", requestId: 1 } });
expectRefineComposerOpen();
const overlay = refineOverlay();
fireEvent.touchEnd(document);
fireEvent.mouseDown(overlay);
fireEvent.mouseUp(overlay);
fireEvent.click(overlay);
expectRefineComposerOpen();
});
it("matches global backdrop-dismiss preference semantics", () => {
const { unmount } = renderDoneTaskDetail({ dismissPreferenceEnabled: false });
openRefineFromActionsMenu();
const disabledOverlay = refineOverlay();
fireEvent.mouseDown(disabledOverlay);
fireEvent.mouseUp(disabledOverlay);
fireEvent.click(disabledOverlay);
expectRefineComposerOpen();
unmount();
renderDoneTaskDetail({ dismissPreferenceEnabled: true });
openRefineFromActionsMenu();
const enabledOverlay = refineOverlay();
fireEvent.mouseDown(enabledOverlay);
fireEvent.mouseUp(enabledOverlay);
expect(screen.queryByPlaceholderText("Enter your feedback here...")).not.toBeInTheDocument();
});
it("preserves explicit close, cancel, and Escape close paths", () => {
const firstRender = renderDoneTaskDetail();
openRefineFromActionsMenu();
const modal = document.querySelector(".detail-refine-modal");
expect(modal).toBeInstanceOf(HTMLElement);
fireEvent.click(within(modal as HTMLElement).getByRole("button", { name: "Close" }));
expect(screen.queryByPlaceholderText("Enter your feedback here...")).not.toBeInTheDocument();
openRefineFromActionsMenu();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByPlaceholderText("Enter your feedback here...")).not.toBeInTheDocument();
firstRender.unmount();
const onClose = vi.fn();
renderDoneTaskDetail({ onClose });
openRefineFromActionsMenu();
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
});
it("preserves refinement validation and submit behavior", async () => {
const user = userEvent.setup();
const addToast = vi.fn();
const onClose = vi.fn();
renderDoneTaskDetail({ addToast, onClose });
openRefineFromActionsMenu();
expect(screen.getByRole("button", { name: "Create Refinement Task" })).toBeDisabled();
expect(refineTask).not.toHaveBeenCalled();
fireEvent.change(screen.getByPlaceholderText("Enter your feedback here..."), { target: { value: "x".repeat(2001) } });
fireEvent.click(screen.getByRole("button", { name: "Create Refinement Task" }));
expect(addToast).toHaveBeenCalledWith("Feedback must be 2000 characters or less", "error");
expect(refineTask).not.toHaveBeenCalled();
fireEvent.change(screen.getByPlaceholderText("Enter your feedback here..."), { target: { value: "Please add the missing regression coverage" } });
await user.click(screen.getByRole("button", { name: "Create Refinement Task" }));
await waitFor(() => {
expect(refineTask).toHaveBeenCalledWith("FN-001", "Please add the missing regression coverage", undefined);
expect(addToast).toHaveBeenCalledWith("Refinement task created: FN-002", "success");
expect(onClose).toHaveBeenCalledTimes(1);
});
});
});