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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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 () => {
|
const handleDelete = useCallback(async () => {
|
||||||
let allowResurrection = false;
|
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) {
|
if (task.column !== "archived" && onArchiveTask) {
|
||||||
const deleteChoice = await confirmWithChoice({
|
const deleteChoice = await confirmWithChoice({
|
||||||
@@ -1975,12 +1987,12 @@ export function TaskDetailContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
closeBeforeDeleteRequest();
|
||||||
if (githubIssueAction) {
|
if (githubIssueAction) {
|
||||||
await onDeleteTask(task.id, { githubIssueAction, allowResurrection });
|
await onDeleteTask(task.id, { githubIssueAction, allowResurrection });
|
||||||
} else {
|
} else {
|
||||||
await onDeleteTask(task.id, { allowResurrection });
|
await onDeleteTask(task.id, { allowResurrection });
|
||||||
}
|
}
|
||||||
requestClose();
|
|
||||||
const issueSuffix = trackedIssue?.owner && trackedIssue.repo && trackedIssue.number && githubIssueAction
|
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}` })}`
|
? ` ${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 {
|
try {
|
||||||
|
closeBeforeDeleteRequest();
|
||||||
await onDeleteTask(task.id, {
|
await onDeleteTask(task.id, {
|
||||||
removeDependencyReferences: true,
|
removeDependencyReferences: true,
|
||||||
removeLineageReferences: true,
|
removeLineageReferences: true,
|
||||||
githubIssueAction,
|
githubIssueAction,
|
||||||
allowResurrection,
|
allowResurrection,
|
||||||
});
|
});
|
||||||
requestClose();
|
|
||||||
addToast(t("taskDetail.delete.deletedAfterRemovingDeps", "Deleted {{id}} after removing dependency references", { id: task.id }), "info");
|
addToast(t("taskDetail.delete.deletedAfterRemovingDeps", "Deleted {{id}} after removing dependency references", { id: task.id }), "info");
|
||||||
} catch (retryErr) {
|
} catch (retryErr) {
|
||||||
const lineageConflict = extractLineageDeleteConflict(retryErr);
|
const lineageConflict = extractLineageDeleteConflict(retryErr);
|
||||||
@@ -2028,13 +2040,13 @@ export function TaskDetailContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
closeBeforeDeleteRequest();
|
||||||
await onDeleteTask(task.id, {
|
await onDeleteTask(task.id, {
|
||||||
removeDependencyReferences: true,
|
removeDependencyReferences: true,
|
||||||
removeLineageReferences: true,
|
removeLineageReferences: true,
|
||||||
githubIssueAction,
|
githubIssueAction,
|
||||||
allowResurrection,
|
allowResurrection,
|
||||||
});
|
});
|
||||||
requestClose();
|
|
||||||
addToast(t("taskDetail.delete.deletedAfterUnlinkLineage", "Deleted {{id}} after unlinking lineage references", { id: task.id }), "info");
|
addToast(t("taskDetail.delete.deletedAfterUnlinkLineage", "Deleted {{id}} after unlinking lineage references", { id: task.id }), "info");
|
||||||
} catch (lineageRetryErr) {
|
} catch (lineageRetryErr) {
|
||||||
addToast(getErrorMessage(lineageRetryErr), "error");
|
addToast(getErrorMessage(lineageRetryErr), "error");
|
||||||
@@ -2061,13 +2073,13 @@ export function TaskDetailContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
closeBeforeDeleteRequest();
|
||||||
await onDeleteTask(task.id, {
|
await onDeleteTask(task.id, {
|
||||||
removeDependencyReferences: true,
|
removeDependencyReferences: true,
|
||||||
removeLineageReferences: true,
|
removeLineageReferences: true,
|
||||||
githubIssueAction,
|
githubIssueAction,
|
||||||
allowResurrection,
|
allowResurrection,
|
||||||
});
|
});
|
||||||
requestClose();
|
|
||||||
addToast(t("taskDetail.delete.deletedAfterUnlinkLineage", "Deleted {{id}} after unlinking lineage references", { id: task.id }), "info");
|
addToast(t("taskDetail.delete.deletedAfterUnlinkLineage", "Deleted {{id}} after unlinking lineage references", { id: task.id }), "info");
|
||||||
} catch (retryErr) {
|
} catch (retryErr) {
|
||||||
addToast(getErrorMessage(retryErr), "error");
|
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 { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
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 userEvent from "@testing-library/user-event";
|
||||||
import {
|
import {
|
||||||
makeTask,
|
makeTask,
|
||||||
@@ -14,9 +14,11 @@ import {
|
|||||||
noopMove,
|
noopMove,
|
||||||
noopOpenDetail,
|
noopOpenDetail,
|
||||||
setupTaskDetailModalHooks,
|
setupTaskDetailModalHooks,
|
||||||
|
mockConfirm,
|
||||||
|
mockConfirmWithCheckbox,
|
||||||
mockConfirmWithChoice,
|
mockConfirmWithChoice,
|
||||||
} from "./TaskDetailModal.test-helpers";
|
} from "./TaskDetailModal.test-helpers";
|
||||||
import { TaskDetailModal } from "../TaskDetailModal";
|
import { TaskDetailContent, TaskDetailModal } from "../TaskDetailModal";
|
||||||
|
|
||||||
vi.mock("../BranchGroupCard", () => ({
|
vi.mock("../BranchGroupCard", () => ({
|
||||||
BranchGroupCard: ({ groupId }: { groupId: string }) => <div>Mock Branch Group {groupId}</div>,
|
BranchGroupCard: ({ groupId }: { groupId: string }) => <div>Mock Branch Group {groupId}</div>,
|
||||||
@@ -486,6 +488,140 @@ describe("TaskDetailModal branch group surfacing", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("TaskDetailModal delete affordance", () => {
|
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 () => {
|
it("archives done task when Archive Instead is chosen", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onArchiveTask = vi.fn(async () => makeTask({ column: "archived" }));
|
const onArchiveTask = vi.fn(async () => makeTask({ column: "archived" }));
|
||||||
|
|||||||
Reference in New Issue
Block a user