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
This commit is contained in:
gsxdsm
2026-06-23 21:31:25 -07:00
parent 92f23f81a8
commit cf2f3ba5e4
4 changed files with 160 additions and 6 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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");

View File

@@ -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 }) => <div>Mock Branch Group {groupId}</div>,
@@ -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<ComponentProps<typeof TaskDetailModal>> = {}) {
const onClose = vi.fn();
const Harness = () => {
const [open, setOpen] = React.useState(true);
if (!open) return null;
return (
<TaskDetailModal
initialTab="definition"
task={makeTask({ column: "triage", ...props.task })}
onClose={() => {
onClose();
setOpen(false);
}}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
{...props}
/>
);
};
const result = render(<Harness />);
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<ReturnType<typeof makeTask>>();
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<ReturnType<typeof makeTask>>();
const onDeleteTask = vi.fn(() => pendingDelete.promise);
const onRequestClose = vi.fn();
render(
<TaskDetailContent
initialTab="definition"
embedded
task={makeTask({ column: "triage" })}
onRequestClose={onRequestClose}
onMoveTask={noopMove}
onDeleteTask={onDeleteTask}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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<ReturnType<typeof makeTask>>();
const onDeleteTask = vi
.fn()
.mockRejectedValueOnce(dependencyConflictError(["FN-200"]))
.mockReturnValueOnce(pendingRetry.promise);
const onRequestClose = vi.fn();
mockConfirm.mockResolvedValueOnce(true);
render(
<TaskDetailContent
initialTab="definition"
embedded
task={makeTask({ column: "triage" })}
onRequestClose={onRequestClose}
onMoveTask={noopMove}
onDeleteTask={onDeleteTask}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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" }));