From cf2f3ba5e4bd7dbcbccd7f9b50428ab14955848c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 23 Jun 2026 21:31:25 -0700 Subject: [PATCH] FN-6956: close task detail immediately after confirmed deletes Close task detail surfaces as soon as delete confirmations finish while preserving async result toasts. - Move Task Detail close requests ahead of delete request settlement and reuse them for conflict retry paths. - Cover dialog, mobile-header, embedded host, retry, and cancelled-confirmation delete behavior in tests. - Document the immediate-close delete behavior and add a patch changeset. Files changed: .changeset/fn-6956-immediate-delete-close.md | 5 + docs/dashboard-guide.md | 1 + .../dashboard/app/components/TaskDetailModal.tsx | 20 ++- .../components/__tests__/TaskDetailModal.test.tsx | 140 ++++++++++++++++++++- 4 files changed, 160 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-6956 Fusion-Task-Lineage: 02f70c0c-c807-4a22-945e-646acbfe8def --- .changeset/fn-6956-immediate-delete-close.md | 5 + docs/dashboard-guide.md | 1 + .../app/components/TaskDetailModal.tsx | 20 ++- .../__tests__/TaskDetailModal.test.tsx | 140 +++++++++++++++++- 4 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 .changeset/fn-6956-immediate-delete-close.md diff --git a/.changeset/fn-6956-immediate-delete-close.md b/.changeset/fn-6956-immediate-delete-close.md new file mode 100644 index 0000000000..eb9af5a114 --- /dev/null +++ b/.changeset/fn-6956-immediate-delete-close.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Close task detail dialogs and embedded task-detail hosts immediately after delete confirmations complete, while delete requests continue reporting success or error toasts asynchronously. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 0ce4b15586..5260be1c59 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -927,6 +927,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. - Task metadata keeps priority, execution mode, provenance, 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. +- 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. - Backstop reconciliation runs every 15 minutes to close tracked GitHub issues for soft-deleted and archived tasks even after restart; the sweep is paginated so large archive backlogs are eventually drained. diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 4b6c5b929e..9ce9f76d86 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -1888,6 +1888,18 @@ export function TaskDetailContent({ const handleDelete = useCallback(async () => { let allowResurrection = false; + let deleteCloseRequested = false; + const closeBeforeDeleteRequest = () => { + if (deleteCloseRequested) { + return; + } + /* + FNXC:TaskDetailDelete 2026-06-23-10:55: + Task detail hosts must close optimistically after the operator completes every required delete prompt and before the server delete request settles. Keep async success/error toasts attached to the delete promise so conflict handling and failure reporting continue after the modal, embedded panel, or floating host is gone. + */ + requestClose(); + deleteCloseRequested = true; + }; if (task.column !== "archived" && onArchiveTask) { const deleteChoice = await confirmWithChoice({ @@ -1975,12 +1987,12 @@ export function TaskDetailContent({ } try { + closeBeforeDeleteRequest(); if (githubIssueAction) { await onDeleteTask(task.id, { githubIssueAction, allowResurrection }); } else { await onDeleteTask(task.id, { allowResurrection }); } - requestClose(); const issueSuffix = trackedIssue?.owner && trackedIssue.repo && trackedIssue.number && githubIssueAction ? ` ${t("taskDetail.delete.issueSuffix", "and {{action}} issue {{ref}}", { action: githubIssueAction === "close" ? t("taskDetail.delete.actionClosed", "closed") : githubIssueAction === "delete" ? t("taskDetail.delete.actionDeleted", "deleted") : t("taskDetail.delete.actionLeft", "left"), ref: `${trackedIssue.owner}/${trackedIssue.repo}#${trackedIssue.number}` })}` : ""; @@ -2001,13 +2013,13 @@ export function TaskDetailContent({ } try { + closeBeforeDeleteRequest(); await onDeleteTask(task.id, { removeDependencyReferences: true, removeLineageReferences: true, githubIssueAction, allowResurrection, }); - requestClose(); addToast(t("taskDetail.delete.deletedAfterRemovingDeps", "Deleted {{id}} after removing dependency references", { id: task.id }), "info"); } catch (retryErr) { const lineageConflict = extractLineageDeleteConflict(retryErr); @@ -2028,13 +2040,13 @@ export function TaskDetailContent({ } try { + closeBeforeDeleteRequest(); await onDeleteTask(task.id, { removeDependencyReferences: true, removeLineageReferences: true, githubIssueAction, allowResurrection, }); - requestClose(); addToast(t("taskDetail.delete.deletedAfterUnlinkLineage", "Deleted {{id}} after unlinking lineage references", { id: task.id }), "info"); } catch (lineageRetryErr) { addToast(getErrorMessage(lineageRetryErr), "error"); @@ -2061,13 +2073,13 @@ export function TaskDetailContent({ } try { + closeBeforeDeleteRequest(); await onDeleteTask(task.id, { removeDependencyReferences: true, removeLineageReferences: true, githubIssueAction, allowResurrection, }); - requestClose(); addToast(t("taskDetail.delete.deletedAfterUnlinkLineage", "Deleted {{id}} after unlinking lineage references", { id: task.id }), "info"); } catch (retryErr) { addToast(getErrorMessage(retryErr), "error"); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx index c9423f0982..f9163f11fa 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx @@ -4,7 +4,7 @@ FN-6532 made Chat the default TaskDetailModal tab. Tests that assert Definition- */ import { describe, it, expect, vi } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; -import type { ComponentProps } from "react"; +import React, { type ComponentProps } from "react"; import userEvent from "@testing-library/user-event"; import { makeTask, @@ -14,9 +14,11 @@ import { noopMove, noopOpenDetail, setupTaskDetailModalHooks, + mockConfirm, + mockConfirmWithCheckbox, mockConfirmWithChoice, } from "./TaskDetailModal.test-helpers"; -import { TaskDetailModal } from "../TaskDetailModal"; +import { TaskDetailContent, TaskDetailModal } from "../TaskDetailModal"; vi.mock("../BranchGroupCard", () => ({ BranchGroupCard: ({ groupId }: { groupId: string }) =>
Mock Branch Group {groupId}
, @@ -486,6 +488,140 @@ describe("TaskDetailModal branch group surfacing", () => { }); describe("TaskDetailModal delete affordance", () => { + function dependencyConflictError(dependentIds: string[]) { + const error = new Error("Task has dependents"); + (error as Error & { details: { code: string; dependentIds: string[] } }).details = { + code: "TASK_HAS_DEPENDENTS", + dependentIds, + }; + return error; + } + + function renderClosingTaskDetailModal(props: Partial> = {}) { + const onClose = vi.fn(); + const Harness = () => { + const [open, setOpen] = React.useState(true); + if (!open) return null; + return ( + { + onClose(); + setOpen(false); + }} + onMoveTask={noopMove} + onDeleteTask={noopDelete} + onMergeTask={noopMerge} + onOpenDetail={noopOpenDetail} + addToast={noop} + {...props} + /> + ); + }; + + const result = render(); + return { ...result, onClose }; + } + + it.each(["close", "back"] as const)("closes the %s-header task dialog before a confirmed delete settles", async (mobileHeaderMode) => { + const user = userEvent.setup(); + const pendingDelete = createDeferred>(); + const onDeleteTask = vi.fn(() => pendingDelete.promise); + const { onClose } = renderClosingTaskDetailModal({ + mobileHeaderMode, + onDeleteTask, + }); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Delete task" })); + + await waitFor(() => expect(onDeleteTask).toHaveBeenCalledWith("FN-099", { allowResurrection: false })); + expect(onClose).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + + pendingDelete.resolve(makeTask()); + }); + + it("closes an embedded task-detail host before a confirmed delete settles", async () => { + const user = userEvent.setup(); + const pendingDelete = createDeferred>(); + const onDeleteTask = vi.fn(() => pendingDelete.promise); + const onRequestClose = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Delete task" })); + + await waitFor(() => expect(onDeleteTask).toHaveBeenCalledWith("FN-099", { allowResurrection: false })); + expect(onRequestClose).toHaveBeenCalledTimes(1); + + pendingDelete.resolve(makeTask()); + }); + + it("closes embedded retry deletes before the force-delete retry settles", async () => { + const user = userEvent.setup(); + const pendingRetry = createDeferred>(); + const onDeleteTask = vi + .fn() + .mockRejectedValueOnce(dependencyConflictError(["FN-200"])) + .mockReturnValueOnce(pendingRetry.promise); + const onRequestClose = vi.fn(); + mockConfirm.mockResolvedValueOnce(true); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Delete task" })); + + await waitFor(() => expect(onDeleteTask).toHaveBeenCalledTimes(2)); + expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-099", { + removeDependencyReferences: true, + removeLineageReferences: true, + githubIssueAction: undefined, + allowResurrection: false, + }); + expect(onRequestClose).toHaveBeenCalledTimes(1); + + pendingRetry.resolve(makeTask()); + }); + + it("keeps the dialog open when the delete confirmation is cancelled", async () => { + const user = userEvent.setup(); + const onDeleteTask = vi.fn(async () => makeTask()); + mockConfirmWithCheckbox.mockResolvedValueOnce({ choice: "cancel", checkboxValue: false }); + const { onClose } = renderClosingTaskDetailModal({ onDeleteTask }); + + await user.click(screen.getByRole("button", { name: "Delete task" })); + + expect(onDeleteTask).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + it("archives done task when Archive Instead is chosen", async () => { const user = userEvent.setup(); const onArchiveTask = vi.fn(async () => makeTask({ column: "archived" }));