FN-071: delete tasks when descriptions are cleared

Clear a saved Task Detail description through the existing confirmed deletion lifecycle.

- Detect blanking a populated description during autosave and explicit save.
- Reuse destructive-action confirmation preferences and guard against stale drafts or duplicate deletion requests.
- Add regression coverage, operator documentation, and a patch changeset.

Files changed:
 .changeset/fn-071-clear-description-delete.md      |   7 ++
 docs/dashboard-guide.md                            |   1 +
 .../dashboard/app/components/TaskDetailModal.tsx   |  65 +++++++++--
 ...lModal.inline-editing-and-integrations.test.tsx | 126 +++++++++++++++++++++
 4 files changed, 189 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-071

Fusion-Task-Lineage: bec428c4-8c08-47c5-b7c5-09692e101a26

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-20 06:10:34 +00:00
parent b8a70a544d
commit 0fce621729
4 changed files with 189 additions and 10 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Clear a Task Detail description to delete the task through configured confirmation.
category: fix
dev: Reuses the existing task deletion lifecycle and confirmation preference.

View File

@@ -368,6 +368,7 @@ FNXC:PlannerOversight 2026-07-18-12:00: FN-8247 requires Stop to disable both li
FNXC:PlannerOversight 2026-07-18-14:00: FN-8263 requires the task-detail Eye to remain visible and lit for an effective session advisor even when lifecycle oversight is off or awaiting workflow resolution. Advisor-only menus expose only the advisor toggle; lifecycle controls wait for resolved active oversight. -->
- The task detail modal's inline meta-controls cluster follows Quick Add order: compact **Attach file** (`data-testid="detail-inline-attach"`), eligible-task **GitHub tracking** toggle (`data-testid="detail-inline-github-toggle"`), **Oversight**, Priority, and Execution mode. Attach opens the existing task attachment picker; GitHub toggles the existing tracking setting and is omitted for GitLab-tracked or non-editable tasks. The Oversight control exposes planner-overseer actions behind a compact **"Oversight" overflow-menu button** (`data-testid="detail-oversight-menu-trigger"`, `Eye` when the oversight level is active or the Session advisor is enabled, otherwise `EyeOff`, `aria-haspopup="menu"`) on every viewport, desktop and mobile alike. Clicking the trigger opens a `role="menu"` popover (mirroring the existing move-action dropdown pattern) containing: a **quick oversight-level select** (`data-testid="detail-oversight-level-select"`) that writes the per-task `plannerOversightLevel` override (Off/Observe/Steer/Autonomous recovery) or clears it back to the inherited workflow/project default via an "Inherit" option; a **manual nudge** button (`data-testid="detail-overseer-nudge"`) that asks the overseer to inject one guidance-only steering comment into the currently watched stage right now (never a merge/PR/destructive action), disabled when the overseer is off/inactive or the task is user-paused/done/archived/`autoMerge:false` in-review; a **Stop** button (`data-testid="detail-overseer-stop"`) that disables both active lifecycle oversight and the session advisor for the task (confirmation-gated), including its live advisor runtime; it is hidden once oversight is already off; and an **explain current action** button (`data-testid="detail-overseer-explain"`) that toggles a small read-only panel (`data-testid="detail-overseer-explain-panel"`) showing the overseer's watched stage, reason, last action, and attempt count/limit, with a non-empty-shell inactive state when the overseer is not currently watching. All three action controls call the `POST /tasks/:id/overseer/nudge`, `POST /tasks/:id/overseer/stop`, and `GET /tasks/:id/overseer/explain` routes. The trigger is withheld only for an unresolved task with neither lifecycle oversight controls nor a session-advisor applicability signal. When the effective session advisor is on (task override, project default, or workflow legacy setting), it remains visible and lit even while lifecycle oversight is off or unresolved; toggling it repaints the icon immediately. In the advisor-only unresolved state, the menu contains only the Session advisor toggle: the level select, Nudge, Stop, Explain, and Interventions controls remain hidden until lifecycle oversight resolves and is active.
<!-- FNXC:PlannerOversight 2026-07-04-20:30: FN-7546 clarifies the cluster above — operators reported the buttons were unlabeled and looked inert, with only a hover title explaining why. Adds a visible group label and an always-visible disabled-reason line, and makes Explain always openable since it never mutates anything. -->
- In an editable Task Detail, clearing a previously populated description and saving starts the standard task deletion flow instead of saving a blank description. **Settings → Global → General → Skip confirmation dialogs for critical actions** controls this deletion just as it controls other destructive task actions: disabled shows the centralized confirmation dialog; enabled accepts its primary deletion action without showing a dialog.
- The controls inside the Oversight menu carry a visible, non-interactive **`"Overseer controls"` group label** (`data-testid="detail-oversight-controls-label"`) so Nudge/Stop/Explain read as an identifiable cluster rather than unlabeled entries; the label is gated by the same `(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff` condition as the buttons, so it never renders when oversight is Off/unresolved (opening the menu in that state shows only the level select). When **Nudge** is disabled, an always-visible helper line (`data-testid="detail-overseer-nudge-disabled-reason"`) states the reason in-DOM (mirroring the existing hover `title`) instead of relying on a mouse-hover tooltip alone. **Explain** is read-only and non-mutating, so it is never disabled purely because the overseer is inactive — clicking it always opens/closes the panel, which shows the overseer's live state when watching or an informative "not currently watching this task" message otherwise. Nudge's mutating enablement rule (`canNudgeOverseer`, including the human-control suppression cases) and Stop's confirmation dialog are unchanged.
<!-- FNXC:PlannerOversight 2026-07-04-19:00: FN-7545 originally introduced this overflow menu for mobile only, collapsing the level-select/nudge/stop/explain controls behind the trigger below the 768px breakpoint while desktop kept an inline cluster.

View File

@@ -1598,6 +1598,8 @@ export function TaskDetailContent({
const [editTitle, setEditTitle] = useState(task.title || "");
const [editDescription, setEditDescription] = useState(task.description || "");
const editDescriptionRef = useRef(editDescription);
editDescriptionRef.current = editDescription;
const [editDependencies, setEditDependencies] = useState<string[]>(task.dependencies || []);
const [editBranch, setEditBranch] = useState(task.branch ?? "");
const [editBaseBranch, setEditBaseBranch] = useState(task.baseBranch ?? "");
@@ -2407,6 +2409,15 @@ export function TaskDetailContent({
const editAutoSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const editAutoSaveRevisionRef = useRef(0);
const editSaveTriggeredReplanRef = useRef(false);
const blankDescriptionDeletePendingRef = useRef(false);
const lastBlankDescriptionDeleteAttemptRef = useRef<string | null>(null);
const handleDeleteRef = useRef<((canProceed?: () => boolean) => Promise<boolean | undefined>) | null>(null);
useEffect(() => {
if (editDescription.trim().length > 0) {
lastBlankDescriptionDeleteAttemptRef.current = null;
}
}, [editDescription]);
const buildEditUpdates = useCallback((includeDescription: boolean) => {
const updates: Record<string, unknown> = {};
@@ -2503,7 +2514,37 @@ export function TaskDetailContent({
return { updates, error: null as string | null };
}, [editBaseBranch, editBranch, editDependencies, editDescription, editExecutionMode, editCredentialInstanceId, editExecutorModel, editNodeId, editPlanningCredentialInstanceId, editPlanningModel, editPriority, editReviewLevel, editSelectedWorkflowSteps, editSourceIssueExternalId, editSourceIssueProvider, editSourceIssueRepository, editSourceIssueUrl, editThinkingLevel, editPlannerOversightLevel, editTitle, editValidatorCredentialInstanceId, editValidatorModel, task]);
const persistEditChanges = useCallback(async (includeDescription: boolean) => {
const requestBlankDescriptionDeletion = useCallback(async (descriptionAtRequest: string, force: boolean): Promise<boolean> => {
if (blankDescriptionDeletePendingRef.current || (!force && lastBlankDescriptionDeleteAttemptRef.current === descriptionAtRequest)) return false;
lastBlankDescriptionDeleteAttemptRef.current = descriptionAtRequest;
blankDescriptionDeletePendingRef.current = true;
try {
/*
FNXC:TaskDescriptionDeletion 2026-08-20-05:48:
Clearing a previously populated Task Detail description is an intentional destructive gesture,
so it must reuse the shared confirmation and deletion lifecycle rather than persist an empty
description. The snapshot fence prevents either debounce timer from deleting a draft restored
while its confirmation is open.
*/
return Boolean(await handleDeleteRef.current?.(() =>
editDescriptionRef.current === descriptionAtRequest
&& editDescriptionRef.current.trim().length === 0,
));
} finally {
blankDescriptionDeletePendingRef.current = false;
}
}, []);
const persistEditChanges = useCallback(async (includeDescription: boolean, forceBlankDescriptionDeletion = false) => {
const trimmedDescription = editDescription.trim();
if (includeDescription && task.description.trim().length > 0 && trimmedDescription.length === 0) {
return requestBlankDescriptionDeletion(editDescription, forceBlankDescriptionDeletion);
}
if (trimmedDescription.length > 0) {
lastBlankDescriptionDeleteAttemptRef.current = null;
}
const { updates, error } = buildEditUpdates(includeDescription);
if (!updates) {
setEditAutoSaveStatus("error");
@@ -2562,7 +2603,7 @@ export function TaskDetailContent({
setIsSaving(false);
}
}
}, [addToast, buildEditUpdates, confirm, detailColumnFlags, onTaskUpdated, projectId, requestClose, task.column, task.executionMode, task.id]);
}, [addToast, buildEditUpdates, confirm, detailColumnFlags, editDescription, onTaskUpdated, projectId, requestBlankDescriptionDeletion, requestClose, task.column, task.description, task.executionMode, task.id]);
const handleAutoSaveDescription = useCallback(async (_description: string) => {
await persistEditChanges(true);
@@ -2570,7 +2611,7 @@ export function TaskDetailContent({
const handleSave = useCallback(async () => {
editSaveTriggeredReplanRef.current = false;
const didSave = await persistEditChanges(true);
const didSave = await persistEditChanges(true, true);
if (!didSave || editSaveTriggeredReplanRef.current) {
return;
}
@@ -3064,19 +3105,20 @@ export function TaskDetailContent({
[task.id, task.steps, onMoveTask, requestClose, addToast, confirm],
);
const handleDelete = useCallback(async () => {
const handleDelete = useCallback(async (canProceed: () => boolean = () => true) => {
let allowResurrection = false;
let deletionSucceeded = false;
let deleteCloseRequested = false;
const closeBeforeDeleteRequest = () => {
if (deleteCloseRequested) {
return;
}
if (!canProceed()) return false;
if (deleteCloseRequested) return true;
/*
FNXC:TaskDetailDelete 2026-07-01-09:40:
Task detail hosts must close optimistically after the operator completes every required delete prompt and before each server delete request starts. Keep this helper idempotent so dependency/lineage retries preserve async prompts and toasts without reopening or repeatedly closing the modal, main panel, list split, or right-dock host.
*/
requestClose();
deleteCloseRequested = true;
return true;
};
if (!isArchivedColumn && onArchiveTask) {
@@ -3165,7 +3207,7 @@ export function TaskDetailContent({
}
try {
closeBeforeDeleteRequest();
if (!closeBeforeDeleteRequest()) return false;
if (githubIssueAction) {
await onDeleteTask(task.id, { githubIssueAction, allowResurrection });
} else {
@@ -3174,7 +3216,9 @@ export function TaskDetailContent({
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}` })}`
: "";
deletionSucceeded = true;
addToast(t("taskDetail.delete.deletedToast", "Deleted {{id}}{{suffix}}", { id: task.id, suffix: issueSuffix }), "info");
return deletionSucceeded;
} catch (err) {
const dependencyConflict = extractDependencyDeleteConflict(err);
if (dependencyConflict && dependencyConflict.dependentIds.length > 0) {
@@ -3264,6 +3308,7 @@ export function TaskDetailContent({
}
}
}, [task.column, task.githubTracking?.enabled, task.githubTracking?.issue, task.id, onDeleteTask, onArchiveTask, requestClose, addToast, confirm, confirmWithChoice, confirmWithCheckbox, isArchivedColumn]);
handleDeleteRef.current = handleDelete;
const handleMerge = useCallback(async () => {
const shouldMerge = await confirm({
@@ -7204,7 +7249,7 @@ export function TaskDetailContent({
*/}
{isTaskReverted(task.sourceMetadata) && (
<>
<button className="btn btn-sm btn-danger" onClick={handleDelete} aria-label={t("taskDetail.reverted.deleteAria", "Delete reverted task")}>{t("taskDetail.delete.btn", "Delete")}</button>
<button className="btn btn-sm btn-danger" onClick={() => void handleDelete()} aria-label={t("taskDetail.reverted.deleteAria", "Delete reverted task")}>{t("taskDetail.delete.btn", "Delete")}</button>
{onReviseTask && <button className="btn btn-sm" onClick={() => { onReviseTask(task); requestClose?.(); }}>{t("taskDetail.revise", "Revise")}</button>}
</>
)}
@@ -7216,7 +7261,7 @@ export function TaskDetailContent({
{isIntakeColumn && !isAwaitingApproval && !canRetryTask && (
<button
className="btn btn-sm btn-danger"
onClick={handleDelete}
onClick={() => void handleDelete()}
aria-label={t("taskDetail.delete.ariaLabel", "Delete task")}
title={t("taskDetail.delete.ariaLabel", "Delete task")}
>

View File

@@ -15,6 +15,7 @@ import {
noopMove,
noopOpenDetail,
mockConfirm,
mockConfirmWithCheckbox,
mockUsePluginUiSlots,
expectBaseRule,
expectSingleStatsRuntimeStatus,
@@ -714,6 +715,131 @@ describe("TaskDetailModal", () => {
});
});
it("deletes a cleared description through the confirmed task callback", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
const onDeleteTask = vi.fn(async () => makeTask({ id: "FN-001" }) as Task);
mockConfirmWithCheckbox.mockResolvedValue({ choice: "primary", checkboxValue: false });
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ id: "FN-001", column: "triage", title: "Original title", description: "Original description" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={onDeleteTask}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(document.querySelector(".modal-edit-btn")!);
fireEvent.change(document.querySelector("#task-form-description")!, { target: { value: " \n\t" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockConfirmWithCheckbox).toHaveBeenCalledWith(expect.objectContaining({ title: "Delete Task", danger: true }));
expect(onDeleteTask).toHaveBeenCalledTimes(1);
});
expect(mockUpdate).not.toHaveBeenCalledWith("FN-001", expect.objectContaining({ description: "" }), undefined);
});
it("keeps a cleared description editable when deletion is cancelled", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
const onDeleteTask = vi.fn(async () => makeTask({ id: "FN-001" }) as Task);
mockConfirmWithCheckbox.mockResolvedValue({ choice: "cancel", checkboxValue: false });
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ id: "FN-001", column: "triage", description: "Original description" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={onDeleteTask}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(document.querySelector(".modal-edit-btn")!);
fireEvent.change(document.querySelector("#task-form-description")!, { target: { value: "" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(mockConfirmWithCheckbox).toHaveBeenCalledTimes(1));
expect(onDeleteTask).not.toHaveBeenCalled();
expect(mockUpdate).not.toHaveBeenCalledWith("FN-001", expect.objectContaining({ description: "" }), undefined);
expect(document.querySelector("#task-form-description")).toBeTruthy();
});
it("does not delete a restored draft from a stale confirmation", async () => {
let resolveConfirmation: ((value: { choice: "primary"; checkboxValue: boolean }) => void) | undefined;
const confirmation = new Promise<{ choice: "primary"; checkboxValue: boolean }>((resolve) => {
resolveConfirmation = resolve;
});
const onDeleteTask = vi.fn(async () => makeTask({ id: "FN-001" }) as Task);
mockConfirmWithCheckbox.mockReturnValue(confirmation);
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ id: "FN-001", column: "triage", description: "Original description" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={onDeleteTask}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(document.querySelector(".modal-edit-btn")!);
const description = document.querySelector("#task-form-description")!;
fireEvent.change(description, { target: { value: "" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(mockConfirmWithCheckbox).toHaveBeenCalledTimes(1));
fireEvent.change(description, { target: { value: "Restored description" } });
await act(async () => resolveConfirmation?.({ choice: "primary", checkboxValue: false }));
expect(onDeleteTask).not.toHaveBeenCalled();
expect(document.querySelector("#task-form-description")).toBeTruthy();
});
it("fences the description debounce and Save click to one embedded-host deletion", async () => {
vi.useFakeTimers();
try {
const onDeleteTask = vi.fn(async () => makeTask({ id: "FN-001" }) as Task);
const onRequestClose = vi.fn();
mockConfirmWithCheckbox.mockResolvedValue({ choice: "primary", checkboxValue: false });
render(
<TaskDetailContent
initialTab="definition"
embedded
task={makeTask({ id: "FN-001", column: "triage", description: "Original description" })}
onOpenDetail={noopOpenDetail}
onMoveTask={noopMove}
onDeleteTask={onDeleteTask}
onMergeTask={noopMerge}
addToast={noop}
onRequestClose={onRequestClose}
/>,
);
fireEvent.click(document.querySelector(".modal-edit-btn")!);
fireEvent.change(document.querySelector("#task-form-description")!, { target: { value: "" } });
fireEvent.click(screen.getByText("Save"));
await act(async () => vi.advanceTimersByTimeAsync(1_500));
expect(onDeleteTask).toHaveBeenCalledTimes(1);
expect(onRequestClose).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it("Save button is enabled in edit mode", () => {
const { container } = render(
<TaskDetailModal