From bf173dad7cd1b1e9703f9e9d4fd97a696147c830 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 1 Aug 2026 13:30:08 -0700 Subject: [PATCH] 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) --- .../fn-8696-reverted-task-resolution.md | 7 +++ docs/dashboard-guide.md | 4 ++ packages/dashboard/app/App.tsx | 2 + .../dashboard/app/components/AppModals.tsx | 2 + packages/dashboard/app/components/Board.tsx | 42 ++++++++++++++++- .../dashboard/app/components/DockTaskList.tsx | 21 ++++++++- .../dashboard/app/components/ListView.tsx | 18 ++++++++ .../dashboard/app/components/TaskCard.css | 4 ++ .../dashboard/app/components/TaskCard.tsx | 9 ++++ .../app/components/TaskDetailModal.tsx | 18 +++++++- .../app/components/__tests__/Board.test.tsx | 46 +++++++++++++++++++ .../__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(-) create mode 100644 .changeset/fn-8696-reverted-task-resolution.md diff --git a/.changeset/fn-8696-reverted-task-resolution.md b/.changeset/fn-8696-reverted-task-resolution.md new file mode 100644 index 0000000000..74c38b5c38 --- /dev/null +++ b/.changeset/fn-8696-reverted-task-resolution.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 8b2974efb9..e0d2e21eec 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index c3885027b1..1330dc0e42 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -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} diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index 24f2682199..14b1ee152d 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -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} diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index ccaab555f6..c31740284f 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -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; /* FNXC:TaskRevert 2026-07-05-00:00 (FN-7525): threaded alongside onArchiveTask/onUnarchiveTask. */ onRevertTask?: (id: string, body?: RevertTaskOptions) => Promise; + /** 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 = {}; 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 && ( +
+

Reverted Tasks

+ {aggregateRevertedTasks.map((task) => ( + + ))} +
+ )} ); @@ -1118,6 +1152,12 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o /> ); })} + {partitionRevertedTasks(selectedWorkflowTasks).reverted.length > 0 && ( +
+

Reverted Tasks

+ {partitionRevertedTasks(selectedWorkflowTasks).reverted.map((task) => )} +
+ )} {selectedWorkflowArchivedColumn && ( ; projectId?: string; onOpenTask?: (task: Task | TaskDetail) => void; + onReviseTask?: (task: Task) => void; onDeleteTask?: (id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; githubIssueAction?: GithubIssueAction; allowResurrection?: boolean }) => Promise; 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, ) : null} + {revertedTasks.length > 0 && ( +
+

Reverted Tasks

+ {revertedTasks.map((task) => )} +
+ )} {isEmpty ? (

{emptyTitle}

diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index d7fd54410a..278fdc571d 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -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 = { triage: "var(--triage)", @@ -248,6 +249,7 @@ interface ListViewProps { tasks: Task[]; onMoveTask: (id: string, column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise; onRetryTask?: (id: string) => Promise; + 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({ }} />
+ {partitionRevertedTasks(tasks).reverted.length > 0 && ( +
+

{t("tasks.revertedTasks", "Reverted Tasks")}

+ {partitionRevertedTasks(tasks).reverted.map((task) => ( +
+ + + {onReviseTask && } +
+ ))} +
+ )} {filteredCount === 0 ? (
{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} diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css index 10b41c3055..52099203a7 100644 --- a/packages/dashboard/app/components/TaskCard.css +++ b/packages/dashboard/app/components/TaskCard.css @@ -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); } diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 7d4c84165d..d1aa7fb34d 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -618,6 +618,8 @@ interface TaskCardProps { onArchiveTask guard). */ onRevertTask?: (id: string, body?: RevertTaskOptions) => Promise; + /** 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({ {t("tasks.revertedBadge", "Reverted")} )} + {showRevertedChip && ( + + {onDeleteTask && } + {onReviseTask && } + + )} {showNearDuplicateChip && ( <> void; onOpenDetail: (task: Task | TaskDetail) => void; // For clicking dependencies onMoveTask: (id: string, column: Column, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise; + /** 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) && ( + <> + + {onReviseTask && } + + )} + {/* 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 diff --git a/packages/dashboard/app/components/__tests__/Board.test.tsx b/packages/dashboard/app/components/__tests__/Board.test.tsx index b69f78408e..dd61fee028 100644 --- a/packages/dashboard/app/components/__tests__/Board.test.tsx +++ b/packages/dashboard/app/components/__tests__/Board.test.tsx @@ -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 }) => ( +
+ {task.title} + {onDeleteTask ? : null} + {onReviseTask ? : null} +
+ ), +})); + // 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, diff --git a/packages/dashboard/app/components/__tests__/DockTaskList.test.tsx b/packages/dashboard/app/components/__tests__/DockTaskList.test.tsx index fdc2f6e37b..91fed6f489 100644 --- a/packages/dashboard/app/components/__tests__/DockTaskList.test.tsx +++ b/packages/dashboard/app/components/__tests__/DockTaskList.test.tsx @@ -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; disableDrag?: boolean }) => { + TaskCard: ({ task, taskColumnFlags, onOpenDetail, onDeleteTask, onReviseTask, disableDrag }: { task: Task | TaskDetail; taskColumnFlags?: { complete?: boolean }; onOpenDetail: (task: Task | TaskDetail) => void; onDeleteTask?: (id: string) => Promise; 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 ? { event.stopPropagation(); onReviseTask(task as Task); }}>Revise : null} ); }, @@ -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( + , + ); + + 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(); diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index fd0e9d8edf..aeb0afcd68 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -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} diff --git a/packages/dashboard/app/components/overflowViewRegistry.tsx b/packages/dashboard/app/components/overflowViewRegistry.tsx index 58470de17f..43870dfe8d 100644 --- a/packages/dashboard/app/components/overflowViewRegistry.tsx +++ b/packages/dashboard/app/components/overflowViewRegistry.tsx @@ -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; 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} diff --git a/packages/dashboard/app/components/useRightDockController.tsx b/packages/dashboard/app/components/useRightDockController.tsx index 627ff6baff..aa3eba1343 100644 --- a/packages/dashboard/app/components/useRightDockController.tsx +++ b/packages/dashboard/app/components/useRightDockController.tsx @@ -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} diff --git a/packages/dashboard/app/utils/__tests__/taskRevert.test.ts b/packages/dashboard/app/utils/__tests__/taskRevert.test.ts index 88d623f64f..3b2aeda079 100644 --- a/packages/dashboard/app/utils/__tests__/taskRevert.test.ts +++ b/packages/dashboard/app/utils/__tests__/taskRevert.test.ts @@ -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. diff --git a/packages/dashboard/app/utils/taskRevert.ts b/packages/dashboard/app/utils/taskRevert.ts index 70994616d0..02c1ddf610 100644 --- a/packages/dashboard/app/utils/taskRevert.ts +++ b/packages/dashboard/app/utils/taskRevert.ts @@ -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(tasks: readonly T[]): { normal: T[]; reverted: T[] } { + const normal: T[] = []; + const reverted: T[] = []; + const seen = new Set(); + 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, diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index c7a2acb8ff..0c09458cdf 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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",