FN-8696: keep reverted tasks out of Done

Keep reverted tasks out of completed views and give every task-detail host a revision recovery path.

- Exclude reverted tasks from complete columns and group them in dedicated recovery lists.
- Provide Delete and Revise actions in cards, lists, docks, modals, and popped-out details.
- Add localized copy, documentation, regression coverage, and a patch changeset.

Files changed:
 .changeset/fn-8696-reverted-task-resolution.md     |  7 ++++
 docs/dashboard-guide.md                            |  4 ++
 packages/dashboard/app/App.tsx                     |  2 +
 packages/dashboard/app/components/AppModals.tsx    |  2 +
 packages/dashboard/app/components/Board.tsx        | 42 +++++++++++++++++++-
 packages/dashboard/app/components/DockTaskList.tsx | 21 +++++++++-
 packages/dashboard/app/components/ListView.tsx     | 18 +++++++++
 packages/dashboard/app/components/TaskCard.css     |  4 ++
 packages/dashboard/app/components/TaskCard.tsx     |  9 +++++
 .../dashboard/app/components/TaskDetailModal.tsx   | 18 ++++++++-
 .../app/components/__tests__/Board.test.tsx        | 46 ++++++++++++++++++++++
 .../app/components/__tests__/DockTaskList.test.tsx | 29 +++++++++++++-
 .../app/components/dashboard/MainContent.tsx       |  4 ++
 .../app/components/overflowViewRegistry.tsx        |  3 ++
 .../app/components/useRightDockController.tsx      |  9 +++++
 .../app/utils/__tests__/taskRevert.test.ts         | 16 +++++++-
 packages/dashboard/app/utils/taskRevert.ts         | 18 +++++++++
 packages/i18n/locales/en/app.json                  |  3 ++
 18 files changed, 249 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-8696
Fusion-Task-Lineage: 7c9550fb-ef8e-4422-8ec1-0de1665413a3
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-01 13:30:08 -07:00
parent 529ab26ba4
commit bf173dad7c
18 changed files with 249 additions and 6 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Move reverted tasks out of Done and provide Delete or Revise recovery actions.
category: fix
dev: Reuse the original task description when opening the New Task revision draft.

View File

@@ -2384,3 +2384,7 @@ may remain static. The inventory opt-outs are `AgentErrorDetailsModal` (brief ac
choice), and `StashConflictModal` (urgent bounded recovery). The executable inventory is guarded by
`modalFloatingWindowContract.test.tsx`; `migratedModalFixtures.tsx` keeps every hosted surface in
per-modal geometry coverage.
### Reverted task resolution
When a completed task is successfully reverted, Fusion removes it from ordinary Done collections. It remains discoverable in the **Reverted Tasks** resolution section in Board, List, and the right dock. Open the task for provenance, choose **Delete** to use the existing guarded deletion flow, or choose **Revise** to open New Task with the original description prefilled.

View File

@@ -2203,6 +2203,8 @@ function AppInner() {
embedded
onOpenDetail={popOutTaskDetailForCurrentView}
onMoveTask={moveTask}
/* FNXC:TaskRevert 2026-08-01-20:27: Popped-out detail preserves Delete-or-Revise recovery for reverted tasks. */
onReviseTask={(task) => modalManager.openNewTaskWithDescription(task.description)}
onDeleteTask={deleteTask}
onMergeTask={mergeTask}
onRetryTask={retryTask}

View File

@@ -329,6 +329,8 @@ export function AppModals({
onOpenDetail={openDetailTaskWithNav}
mobileHeaderMode={modalManager.detailTaskOrigin === "list-mobile" ? "back" : "close"}
onMoveTask={taskOperations.moveTask}
/* FNXC:TaskRevert 2026-08-01-20:27: Modal detail must offer the same revision draft recovery as every reverted-task host. */
onReviseTask={(task) => modalManager.openNewTaskWithDescription(task.description)}
onDeleteTask={taskOperations.deleteTask}
onMergeTask={taskOperations.mergeTask}
onArchiveTask={taskOperations.archiveTask}

View File

@@ -1,6 +1,7 @@
import type { Task, TaskDetail, Column as ColumnType, ColumnId, TaskCreateInput, GithubIssueAction, MergeResult } from "@fusion/core";
import { sortTasksForDisplayColumn, type DoneColumnSortMode } from "./taskSorting";
import { Column } from "./Column";
import { TaskCard } from "./TaskCard";
import "./Lane.css";
import "./Board.css";
import type { ToastType } from "../hooks/useToast";
@@ -24,6 +25,7 @@ import {
writeBoardWorkflowSelection,
} from "../utils/boardWorkflowSelection";
import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu";
import { isTaskReverted, partitionRevertedTasks } from "../utils/taskRevert";
interface BoardProps {
tasks: Task[];
@@ -58,6 +60,8 @@ interface BoardProps {
onUnarchiveTask?: (id: string) => Promise<Task>;
/* FNXC:TaskRevert 2026-07-05-00:00 (FN-7525): threaded alongside onArchiveTask/onUnarchiveTask. */
onRevertTask?: (id: string, body?: RevertTaskOptions) => Promise<RevertTaskResult>;
/** Opens a New Task draft using a reverted task description. */
onReviseTask?: (task: Task) => void;
onDeleteTask?: (id: string, options?: {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
@@ -174,7 +178,7 @@ function columnDefOffersArchiveAllDone(columnDef: { flags: { complete?: boolean;
return columnDef.flags.complete === true && columnDef.flags.archived !== true;
}
export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onRevertTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, onLoadMoreArchivedTasks, archivedHasMore, archivedLoadingMore, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowControlsInHeader = false }: BoardProps) {
export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onRevertTask, onReviseTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, onLoadMoreArchivedTasks, archivedHasMore, archivedLoadingMore, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowControlsInHeader = false }: BoardProps) {
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
/*
FNXC:DoneColumnSorting 2026-06-29-16:57:
@@ -640,6 +644,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
Safety net (defense in depth for the taskWorkflowIds refetch above): a card that passed the selected-workflow membership filter genuinely belongs on THIS board, so it must always land in a rendered lane. If its stored `column` is not one this workflow declares (a workflow edited to drop a column, or a create/refetch race that lands an intake-column card before its lane is known), re-home it for DISPLAY into the workflow's intake/first visible column instead of a `??=`-created bucket that is never rendered. Display-only — the task's stored column is untouched.
*/
for (const task of selectedWorkflowTasks) {
if (isTaskReverted(task.sourceMetadata) && selectedWorkflow.columns.find((column) => column.id === task.column)?.flags.complete) continue;
const columnId = grouped[task.column] !== undefined
? task.column
: (selectedWorkflowCreateColumnId ?? task.column);
@@ -798,6 +803,17 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
[aggregateArchivedBoardColumns, aggregateVisibleBoardColumns],
);
/*
FNXC:TaskRevert 2026-08-01-20:06:
A successful revert is resolution-required work, not completed work. Aggregate the
shared, deduplicated partition once so All Workflows keeps those cards discoverable
after its complete lanes exclude them, including custom complete columns.
*/
const aggregateRevertedTasks = useMemo(
() => partitionRevertedTasks(tasks).reverted,
[tasks],
);
const aggregateTasksByColumn = useMemo(() => {
const grouped: Record<string, Task[]> = {};
for (const column of aggregateBoardColumns) grouped[column.id] = [];
@@ -819,6 +835,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
Aggregate Board grouping must resolve the task's effective workflow before using a shared column id. If one workflow hides `qa` while another shows it, tasks assigned to the hidden `qa` column stay hidden instead of leaking into the visible aggregate lane.
*/
if (workflowColumn?.flags.hiddenFromBoard) continue;
if (isTaskReverted(task.sourceMetadata) && workflowColumn?.flags.complete) continue;
if (!workflowColumn) {
/*
FNXC:WorkflowBoard 2026-07-12-23:35:
@@ -1034,6 +1051,23 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
/>
);
})}
{aggregateRevertedTasks.length > 0 && (
<section className="reverted-tasks-section" aria-label="Reverted Tasks" data-testid="board-reverted-tasks">
<h2>Reverted Tasks</h2>
{aggregateRevertedTasks.map((task) => (
<TaskCard
key={`reverted-${task.id}`}
task={task}
taskColumnFlags={blockerFanoutColumnFlagsByTaskId.get(task.id)}
onOpenDetail={onOpenDetail}
onDeleteTask={onDeleteTask}
onReviseTask={onReviseTask}
addToast={addToast}
disableDrag
/>
))}
</section>
)}
</main>
</div>
);
@@ -1118,6 +1152,12 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
/>
);
})}
{partitionRevertedTasks(selectedWorkflowTasks).reverted.length > 0 && (
<section className="reverted-tasks-section" aria-label="Reverted Tasks" data-testid="board-reverted-tasks">
<h2>Reverted Tasks</h2>
{partitionRevertedTasks(selectedWorkflowTasks).reverted.map((task) => <TaskCard key={`reverted-${task.id}`} task={task} taskColumnFlags={blockerFanoutColumnFlagsByTaskId.get(task.id)} onOpenDetail={onOpenDetail} onDeleteTask={onDeleteTask} onReviseTask={onReviseTask} addToast={addToast} disableDrag />)}
</section>
)}
{selectedWorkflowArchivedColumn && (
<Column
key={selectedWorkflowArchivedColumn.id}

View File

@@ -1,5 +1,6 @@
import { useCallback, useMemo, useState } from "react";
import { isArchivedColumnRole, isCompleteColumnRole } from "../utils/columnRoles";
import { partitionRevertedTasks } from "../utils/taskRevert";
import type { GithubIssueAction, Task, TaskDetail } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { TaskCard } from "./TaskCard";
@@ -11,6 +12,7 @@ export interface DockTaskListProps {
tasks: Array<Task | TaskDetail>;
projectId?: string;
onOpenTask?: (task: Task | TaskDetail) => void;
onReviseTask?: (task: Task) => void;
onDeleteTask?: (id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; githubIssueAction?: GithubIssueAction; allowResurrection?: boolean }) => Promise<Task>;
addToast?: (message: string, type?: ToastType) => void;
prAuthAvailable?: boolean;
@@ -41,6 +43,7 @@ export function DockTaskList({ columnFlagsByTaskId,
projectId,
onOpenTask,
onDeleteTask,
onReviseTask,
addToast = () => {},
prAuthAvailable = false,
autoMergeEnabled = false,
@@ -66,13 +69,21 @@ export function DockTaskList({ columnFlagsByTaskId,
const flags = columnFlagsByTaskId?.get(task.id);
return { complete: isCompleteColumnRole(flags, task.column), archived: isArchivedColumnRole(flags, task.column) };
}, [columnFlagsByTaskId]);
const doneTasks = useMemo(() => tasks.filter((task) => isTerminal(task).complete), [tasks, isTerminal]);
/*
FNXC:TaskRevert 2026-08-01-20:06:
Resolution cards must receive the task's resolved traits, not legacy column names.
A custom complete column otherwise hides the reverted treatment and its Delete and
Revise actions in the dock even though the same task is correctly partitioned here.
*/
const revertedTasks = useMemo(() => partitionRevertedTasks(tasks as Task[]).reverted, [tasks]);
const doneTasks = useMemo(() => tasks.filter((task) => isTerminal(task).complete && !revertedTasks.some((reverted) => reverted.id === task.id)), [tasks, isTerminal, revertedTasks]);
const visibleTasks = useMemo(() => tasks.filter((task) => {
const roles = isTerminal(task);
if (roles.archived) return false;
if (revertedTasks.some((reverted) => reverted.id === task.id)) return false;
if (roles.complete) return showDone;
return true;
}), [showDone, tasks, isTerminal]);
}), [showDone, tasks, isTerminal, revertedTasks]);
const hasDoneTasks = doneTasks.length > 0;
const isEmpty = visibleTasks.length === 0;
const emptyTitle = tasks.length === 0 ? "No tasks yet" : "No active tasks";
@@ -97,6 +108,12 @@ export function DockTaskList({ columnFlagsByTaskId,
</button>
</div>
) : null}
{revertedTasks.length > 0 && (
<section className="dock-task-list__reverted" aria-label="Reverted Tasks" data-testid="dock-reverted-tasks">
<h3>Reverted Tasks</h3>
{revertedTasks.map((task) => <TaskCard key={`reverted-${task.id}`} task={task} taskColumnFlags={columnFlagsByTaskId?.get(task.id)} projectId={projectId} onOpenDetail={handleOpenTask} onDeleteTask={onDeleteTask} onReviseTask={onReviseTask} addToast={addToast} disableDrag />)}
</section>
)}
{isEmpty ? (
<div className="dock-task-list__empty" data-testid="dock-task-list-empty">
<p className="dock-task-list__empty-title">{emptyTitle}</p>

View File

@@ -36,6 +36,7 @@ import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
import { useUnmappedWorkflowRefetch } from "../hooks/useUnmappedWorkflowRefetch";
import { TaskContextMenu, buildTaskActionMenuModel, getTaskPrAutomationLabel, type TaskContextMenuColumnMetadata, type TaskMenuActionDescriptor } from "./TaskContextMenu";
import type { DetailTaskOpenOptions } from "../hooks/useModalManager";
import { isTaskReverted, partitionRevertedTasks } from "../utils/taskRevert";
const COLUMN_COLOR_MAP: Record<Column, string> = {
triage: "var(--triage)",
@@ -248,6 +249,7 @@ interface ListViewProps {
tasks: Task[];
onMoveTask: (id: string, column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
onRetryTask?: (id: string) => Promise<Task>;
onReviseTask?: (task: Task) => void;
onDeleteTask: (id: string, options?: {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
@@ -350,6 +352,7 @@ export function ListView({
onMoveTask,
onRetryTask,
onDeleteTask,
onReviseTask,
onPauseTask,
onUnpauseTask,
onArchiveTask,
@@ -1158,6 +1161,7 @@ export function ListView({
columnFiltered.forEach((task) => {
const column = workflowMode ? task.column : (isColumn(task.column) ? task.column : DEFAULT_COLUMN);
if (groups[column] !== undefined) {
if (isTaskReverted(task.sourceMetadata) && listColumns.find((candidate) => candidate.id === column)?.flags.complete) return;
groups[column].push(task);
return;
}
@@ -3018,6 +3022,18 @@ export function ListView({
}}
/>
</div>
{partitionRevertedTasks(tasks).reverted.length > 0 && (
<section className="list-reverted-tasks" aria-label="Reverted Tasks" data-testid="list-reverted-tasks">
<h2>{t("tasks.revertedTasks", "Reverted Tasks")}</h2>
{partitionRevertedTasks(tasks).reverted.map((task) => (
<div key={`reverted-${task.id}`} className="list-card">
<button type="button" className="btn" onClick={() => onOpenDetail(task)}>{task.id}: {task.title}</button>
<button type="button" className="btn" onClick={() => void handleListTaskDelete(task)}>{t("tasks.delete", "Delete")}</button>
{onReviseTask && <button type="button" className="btn" onClick={() => onReviseTask(task)}>{t("tasks.revise", "Revise")}</button>}
</div>
))}
</section>
)}
{filteredCount === 0 ? (
<div className="list-empty">
{searchQuery ? t("listView.noTasksMatch", "No tasks match your filter") : t("listView.noTasksYet", "No tasks yet")}
@@ -3588,6 +3604,8 @@ export function ListView({
onRequestClose={closeEmbeddedTaskDetail}
onOpenDetail={handleEmbeddedOpenDetail}
onMoveTask={onMoveTask}
/* FNXC:TaskRevert 2026-08-01-20:27: Split detail receives the list recovery callback so reverted tasks remain revisable here. */
onReviseTask={onReviseTask}
onDeleteTask={onDeleteTask}
onMergeTask={onMergeTask}
onRetryTask={onRetryTask}

View File

@@ -2163,3 +2163,7 @@ The three-dot menu is the sole card move/action entry point. Keep this shared bu
.card-field-badge--overflow {
font-weight: 600;
}
/* FNXC:TaskRevert 2026-08-01-19:51: Reverted tasks require visible resolution controls, not hover-only menu actions. */
.card-reverted-actions { display: inline-flex; gap: var(--space-1); }
.reverted-tasks-section { display: grid; gap: var(--space-2); padding: var(--space-3); }

View File

@@ -618,6 +618,8 @@ interface TaskCardProps {
onArchiveTask guard).
*/
onRevertTask?: (id: string, body?: RevertTaskOptions) => Promise<RevertTaskResult>;
/** Resolution action for a successfully reverted task. */
onReviseTask?: (task: Task) => void;
onDeleteTask?: (id: string, options?: {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
@@ -996,6 +998,7 @@ function TaskCardComponent({
onUnarchiveTask,
onRevertTask,
onDeleteTask,
onReviseTask,
onPauseTask,
onRetryTask,
onUnpauseTask,
@@ -3262,6 +3265,12 @@ function TaskCardComponent({
<span>{t("tasks.revertedBadge", "Reverted")}</span>
</span>
)}
{showRevertedChip && (
<span className="card-reverted-actions" aria-label={t("tasks.revertedResolutionActions", "Reverted task resolution actions")}>
{onDeleteTask && <button type="button" className="btn" onClick={(event) => { event.stopPropagation(); void handleTaskActionDelete(); }}>{t("tasks.delete", "Delete")}</button>}
{onReviseTask && <button type="button" className="btn" onClick={(event) => { event.stopPropagation(); onReviseTask(task); }}>{t("tasks.revise", "Revise")}</button>}
</span>
)}
{showNearDuplicateChip && (
<>
<span

View File

@@ -23,7 +23,7 @@ import {
import { resolveEffectivePlannerOversightLevel } from "../../../core/src/workflow-settings-resolver";
import { resolveTaskSessionAdvisorEnabled } from "../../../core/src/session-advisor";
import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical";
import { getRevertOfId, findOpenUndoTaskForSource } from "../utils/taskRevert";
import { getRevertOfId, findOpenUndoTaskForSource, isTaskReverted } from "../utils/taskRevert";
import {
isArchivedColumnRole,
isCompleteColumnRole,
@@ -368,6 +368,8 @@ export interface TaskDetailModalProps {
onClose: () => void;
onOpenDetail: (task: Task | TaskDetail) => void; // For clicking dependencies
onMoveTask: (id: string, column: Column, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
/** Opens a New Task draft from a reverted task description. */
onReviseTask?: (task: Task) => void;
onDeleteTask: (id: string, options?: {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
@@ -760,6 +762,7 @@ export function TaskDetailContent({
onOpenDetail,
onMoveTask,
onDeleteTask,
onReviseTask,
onArchiveTask,
onRevertTask,
onMergeTask,
@@ -6817,6 +6820,19 @@ export function TaskDetailContent({
</>
)}
{/*
FNXC:TaskRevert 2026-08-01-19:51:
A reverted task remains accessible for provenance, but cannot present as ordinary
completed work. Detail therefore retains guarded Delete and routes Revise through
the shared New Task draft callback with the original description.
*/}
{isTaskReverted(task.sourceMetadata) && (
<>
<button className="btn btn-sm btn-danger" onClick={handleDelete} aria-label="Delete reverted task">Delete</button>
{onReviseTask && <button className="btn btn-sm" onClick={() => { onReviseTask(task); requestClose?.(); }}>Revise</button>}
</>
)}
{/* Standalone Delete button for INTAKE-lane tasks — they hide the Actions
dropdown (see condition below) so the user has no quick way to delete a
freshly-created task otherwise. Keyed on the intake trait rather than the

View File

@@ -129,6 +129,22 @@ vi.mock("../Column", () => ({
}),
}));
/*
FNXC:TaskRevert 2026-08-01-20:06:
The aggregate resolution section renders TaskCard directly rather than through the
Column mock. Keep this focused Board suite isolated from TaskCard's badge-fetching
hooks while exposing the card traits and Delete/Revise callbacks it must receive.
*/
vi.mock("../TaskCard", () => ({
TaskCard: ({ task, taskColumnFlags, onDeleteTask, onReviseTask }: { task: Task; taskColumnFlags?: { complete?: boolean }; onDeleteTask?: unknown; onReviseTask?: (task: Task) => void }) => (
<article data-testid={`board-resolution-card-${task.id}`} data-complete={String(taskColumnFlags?.complete === true)}>
<span>{task.title}</span>
{onDeleteTask ? <button type="button">Delete</button> : null}
{onReviseTask ? <button type="button" onClick={() => onReviseTask(task)}>Revise</button> : null}
</article>
),
}));
// Mock Lane so the multi-lane Board tests assert grouping/ordering without
// pulling in the full Column tree.
vi.mock("../Lane", () => ({
@@ -1360,6 +1376,36 @@ describe("Board", () => {
expect(screen.getByTestId("column-in-progress").getAttribute("data-has-plan-auto-approve-toggle")).toBe("no");
});
it("keeps reverted custom-complete tasks discoverable in All Workflows with resolution actions", async () => {
const projectId = "project-all-reverted-resolution";
const shippedWorkflow = {
id: "wf-shipped",
name: "Shipped Flow",
columns: [
{ id: "intake", name: "Intake", flags: { intake: true } },
{ id: "shipped", name: "Shipped", flags: { complete: true } },
],
};
const reverted = mkTask({
id: "FN-REVERTED",
title: "Cancelled custom task",
column: "shipped",
sourceMetadata: { revertedAt: "2026-08-01T00:00:00.000Z" },
});
enableFlag({ [reverted.id]: shippedWorkflow.id }, [DEFAULT_WORKFLOW, shippedWorkflow]);
window.localStorage.setItem(scopedKey(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, projectId), ALL_WORKFLOWS_BOARD_VIEW_ID);
renderBoard({ projectId, tasks: [reverted], onDeleteTask: vi.fn().mockResolvedValue(reverted), onReviseTask: vi.fn() });
await waitFor(() => expect(screen.getByTestId("column-shipped")).toBeDefined());
expect(screen.getByTestId("column-shipped")).not.toHaveAttribute("data-tasks", expect.stringContaining(reverted.id));
const resolution = screen.getByTestId("board-reverted-tasks");
expect(screen.getByTestId("board-resolution-card-FN-REVERTED")).toHaveAttribute("data-complete", "true");
expect(resolution).toHaveTextContent("Cancelled custom task");
expect(within(resolution).getByRole("button", { name: "Delete" })).toBeInTheDocument();
expect(within(resolution).getByRole("button", { name: "Revise" })).toBeInTheDocument();
});
it("passes auto-merge toggle to selected workflow human-review columns", async () => {
const workflow = {
...DEFAULT_WORKFLOW,

View File

@@ -11,7 +11,7 @@ The mock records each TaskCard mount so tests can assert row identity stability:
const { taskCardMountLog } = vi.hoisted(() => ({ taskCardMountLog: [] as string[] }));
vi.mock("../TaskCard", () => ({
TaskCard: ({ task, onOpenDetail, onDeleteTask, disableDrag }: { task: Task | TaskDetail; onOpenDetail: (task: Task | TaskDetail) => void; onDeleteTask?: (id: string) => Promise<Task>; disableDrag?: boolean }) => {
TaskCard: ({ task, taskColumnFlags, onOpenDetail, onDeleteTask, onReviseTask, disableDrag }: { task: Task | TaskDetail; taskColumnFlags?: { complete?: boolean }; onOpenDetail: (task: Task | TaskDetail) => void; onDeleteTask?: (id: string) => Promise<Task>; onReviseTask?: (task: Task) => void; disableDrag?: boolean }) => {
useEffect(() => {
taskCardMountLog.push(task.id);
}, []);
@@ -21,9 +21,11 @@ vi.mock("../TaskCard", () => ({
data-testid={`mock-task-card-${task.id}`}
data-disable-drag={String(disableDrag)}
data-has-delete={String(Boolean(onDeleteTask))}
data-complete={String(taskColumnFlags?.complete === true)}
onClick={() => onOpenDetail(task)}
>
{task.title ?? task.id}
{onReviseTask ? <span data-testid={`mock-task-card-revise-${task.id}`} onClick={(event) => { event.stopPropagation(); onReviseTask(task as Task); }}>Revise</span> : null}
</button>
);
},
@@ -177,6 +179,31 @@ describe("DockTaskList", () => {
FNXC:RightDockTasks 2026-06-28-18:42:
Empty right-dock task states must distinguish a truly empty list from a list whose only rows are completed or archived, so the compact panel never renders blank and the Show Done affordance remains reachable when completed rows exist.
*/
it("routes dock reverted cards through resolved column flags and revise", () => {
const reverted = {
...makeTask("FN-REVERTED", "Cancelled task", "shipped"),
description: "first line\nsecond line",
sourceMetadata: { revertedAt: "2026-08-01T00:00:00.000Z" },
} as Task;
const onReviseTask = vi.fn();
render(
<DockTaskList
tasks={[reverted]}
columnFlagsByTaskId={new Map([[reverted.id, { complete: true }]])}
onOpenTask={vi.fn()}
onDeleteTask={vi.fn()}
onReviseTask={onReviseTask}
addToast={vi.fn()}
/>,
);
expect(screen.getByTestId("dock-reverted-tasks")).toBeInTheDocument();
expect(screen.getByTestId("mock-task-card-FN-REVERTED")).toHaveAttribute("data-complete", "true");
fireEvent.click(screen.getByTestId("mock-task-card-revise-FN-REVERTED"));
expect(onReviseTask).toHaveBeenCalledWith(reverted);
});
it("renders distinct empty states for no tasks, only done tasks, and only archived tasks", () => {
const { rerender } = render(<DockTaskList tasks={[]} onOpenTask={vi.fn()} addToast={vi.fn()} />);

View File

@@ -847,6 +847,7 @@ export function MainContent({
onArchiveTask={archiveTask}
onUnarchiveTask={unarchiveTask}
onRevertTask={revertTask}
onReviseTask={(task) => modalManager.openNewTaskWithDescription(task.description)}
onDeleteTask={deleteTask}
onArchiveAllDone={archiveAllDone}
onLoadArchivedTasks={loadArchivedTasks}
@@ -900,6 +901,7 @@ export function MainContent({
onOpenDetail={(value) => openTaskDetailInMainPanel(value, "chat")}
onMoveTask={moveTask}
onDeleteTask={deleteTask}
onReviseTask={(task) => modalManager.openNewTaskWithDescription(task.description)}
onMergeTask={mergeTask}
onRetryTask={retryTask}
onResetTask={resetTask}
@@ -962,6 +964,7 @@ export function MainContent({
onArchiveTask={archiveTask}
onUnarchiveTask={unarchiveTask}
onRevertTask={revertTask}
onReviseTask={(task) => modalManager.openNewTaskWithDescription(task.description)}
onDeleteTask={deleteTask}
onArchiveAllDone={archiveAllDone}
onLoadArchivedTasks={loadArchivedTasks}
@@ -997,6 +1000,7 @@ export function MainContent({
onMoveTask={moveTask}
onRetryTask={retryTask}
onDeleteTask={deleteTask}
onReviseTask={(task) => modalManager.openNewTaskWithDescription(task.description)}
onPauseTask={pauseTask}
onUnpauseTask={unpauseTask}
onArchiveTask={archiveTask}

View File

@@ -81,6 +81,8 @@ export interface OverflowViewRenderProps {
onOpenSettings?: (section?: string) => void;
onOpenTaskDetail?: (taskId: string) => void;
onOpenTaskInDock?: (task: Task | TaskDetail) => void;
/** Opens New Task with a reverted source task's original description. */
onReviseTask?: (task: Task | TaskDetail) => void;
onDeleteTask?: (id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; githubIssueAction?: GithubIssueAction; allowResurrection?: boolean }) => Promise<Task>;
onOpenDetail?: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
onSendSelectionToTask?: (description: string) => void;
@@ -161,6 +163,7 @@ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [
columnFlagsByTaskId={props.columnFlagsByTaskId}
projectId={props.projectId}
onOpenTask={props.onOpenTaskInDock}
onReviseTask={props.onReviseTask}
onDeleteTask={props.onDeleteTask}
addToast={props.addToast}
prAuthAvailable={false}

View File

@@ -231,6 +231,13 @@ export function useRightDockController(input: RightDockControllerInput): RightDo
DockTaskList rows must open through the controller's ordinary right-dock task route, not TaskCard's canonical full task modal. Thread one controller-level handler into registry render props so both compact and expanded Tasks lists share popup-setting routing and setting-off dock-detail behavior.
*/
onOpenTaskInDock: openTaskFromDockList,
/*
FNXC:TaskRevert 2026-08-01-20:06:
Dock resolution uses the same New Task prefill owner as every other surface.
Keeping this callback in registry props lets compact and expanded dock hosts revise
the exact source description without introducing a second draft state.
*/
onReviseTask: (task: Task | TaskDetail) => input.onSendSelectionToTask(task.description),
onDeleteTask: input.onDeleteTask,
onOpenDetail: input.openDetailTask,
onSendSelectionToTask: input.onSendSelectionToTask,
@@ -256,6 +263,8 @@ export function useRightDockController(input: RightDockControllerInput): RightDo
onRequestClose={closeDockTask}
onOpenDetail={(value) => input.openDetailTask(value, "chat")}
onMoveTask={input.onMoveTask}
/* FNXC:TaskRevert 2026-08-01-20:27: Right-dock task detail uses the shared New Task draft recovery for reverted tasks. */
onReviseTask={(task) => input.onSendSelectionToTask(task.description)}
onDeleteTask={input.onDeleteTask}
onArchiveTask={input.onArchiveTask}
onRevertTask={input.onRevertTask}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { Task } from "@fusion/core";
import { isTaskReverted, findOpenUndoTaskForSource } from "../taskRevert";
import { isTaskReverted, findOpenUndoTaskForSource, partitionRevertedTasks } from "../taskRevert";
describe("isTaskReverted", () => {
it.each([
@@ -16,6 +16,20 @@ describe("isTaskReverted", () => {
});
});
describe("partitionRevertedTasks", () => {
const task = (id: string, revertedAt?: unknown): Task => ({
id, column: "done", title: id, description: "", createdAt: "", updatedAt: "", dependencies: [], steps: [],
sourceMetadata: revertedAt === undefined ? {} : { revertedAt },
} as unknown as Task);
it("keeps invalid markers normal and returns valid markers once", () => {
const reverted = task("KB-2", "2026-08-01T00:00:00.000Z");
const result = partitionRevertedTasks([task("KB-1", " "), reverted, reverted, task("KB-3", 42)]);
expect(result.normal.map(({ id }) => id)).toEqual(["KB-1", "KB-3"]);
expect(result.reverted.map(({ id }) => id)).toEqual(["KB-2"]);
});
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-23:30:
THE UNDO-TASK LOOKUP CLASSIFIED A NEIGHBOUR'S COLUMN BY ID.

View File

@@ -34,6 +34,24 @@ export function isTaskReverted(sourceMetadata: Task["sourceMetadata"] | undefine
return typeof sourceMetadata?.revertedAt === "string" && sourceMetadata.revertedAt.trim().length > 0;
}
/**
* FNXC:TaskRevert 2026-08-01-19:51:
* A successful revert is not completed work. Keep its persisted column intact for
* provenance, but partition it out of ordinary completed collections so every host
* can expose one consistent resolution path instead of silently losing the task.
*/
export function partitionRevertedTasks<T extends Task>(tasks: readonly T[]): { normal: T[]; reverted: T[] } {
const normal: T[] = [];
const reverted: T[] = [];
const seen = new Set<string>();
for (const task of tasks) {
if (seen.has(task.id)) continue;
seen.add(task.id);
(isTaskReverted(task.sourceMetadata) ? reverted : normal).push(task);
}
return { normal, reverted };
}
export function getRevertOfId(
sourceMetadata: Task["sourceMetadata"] | undefined,
sourceParentTaskId?: string | null,

View File

@@ -8519,6 +8519,9 @@
"reverted": "Reverted {{taskId}} in commit {{sha}}",
"revertedBadge": "Reverted",
"revertedBadgeTitle": "This task's changes were reverted",
"revertedTasks": "Reverted Tasks",
"revertedResolutionActions": "Reverted task resolution actions",
"revise": "Revise",
"revertTask": "Revert this task's changes",
"reviewerModel": "Reviewer Model",
"save": "Save",