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:
7
.changeset/fn-8696-reverted-task-resolution.md
Normal file
7
.changeset/fn-8696-reverted-task-resolution.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()} />);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user