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:
5
.changeset/fn-6956-immediate-delete-close.md
Normal file
5
.changeset/fn-6956-immediate-delete-close.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
Reference in New Issue
Block a user