FN-7525: add Revert/Undo affordance to Done and Archived task cards

Adds a Revert action so operators can undo landed changes for Done/Archived tasks directly from the board.

- Adds onRevertTask wiring through Board, Column, Lane, ListView, TaskDetailModal, and WorktreeGroup surfaces
- Adds a Revert affordance to TaskCard (Done/Archived states) with confirm UX and CSS
- Adds POST /tasks/:id/revert legacy API route supporting "auto" mode with an AI-undo fallback (mode: "ai") on conflict
- Wires useTasks hook and dashboard MainContent/types to support the new revert action
- Adds new i18n strings for the revert affordance
- Adds a minor changeset for @runfusion/fusion documenting the feature
- Adds/updates tests: TaskCard.test.tsx, board-mobile.test.tsx, api-git.test.ts
- Updates docs/dashboard-guide.md and docs/task-management.md

Files changed:
 .changeset/fn-7525-revert-card-affordance.md       |   7 +
 docs/dashboard-guide.md                            |   2 +-
 docs/task-management.md                            |  10 ++
 packages/dashboard/app/App.tsx                     |   7 +-
 packages/dashboard/app/__tests__/api-git.test.ts   |  51 +++++++
 packages/dashboard/app/api/legacy.ts               |  51 +++++++
 packages/dashboard/app/components/AppModals.tsx    |   5 +-
 packages/dashboard/app/components/Board.tsx        |  10 +-
 packages/dashboard/app/components/Column.tsx       |   8 +-
 packages/dashboard/app/components/Lane.tsx         |   5 +-
 packages/dashboard/app/components/ListView.tsx     |  80 ++++++++++-
 packages/dashboard/app/components/TaskCard.css     |  24 +++-
 packages/dashboard/app/components/TaskCard.tsx     | 127 ++++++++++++++++-
 packages/dashboard/app/components/TaskDetailModal.tsx   |  85 ++++++++++++
 packages/dashboard/app/components/WorktreeGroup.tsx     |   6 +
 packages/dashboard/app/components/__tests__/TaskCard.test.tsx     | 151 +++++++++++++++++++++
 packages/dashboard/app/components/__tests__/board-mobile.test.tsx |  63 +++++++++
 packages/dashboard/app/components/dashboard/MainContent.tsx       |   4 +
 packages/dashboard/app/components/dashboard/types.ts    |   8 ++
 packages/dashboard/app/components/useRightDockController.tsx      |   4 +
 packages/dashboard/app/hooks/useTasks.ts           |  21 ++-
 packages/i18n/locales/en/app.json                  |  11 ++
 22 files changed, 718 insertions(+), 22 deletions(-)

Fusion-Task-Id: FN-7525

Fusion-Task-Lineage: b9e2ca94-4ec6-4443-8e41-cf4828018856

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-05 10:29:42 -07:00
parent 94e9d15e38
commit e7cb2f1fed
22 changed files with 718 additions and 22 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add a Revert action to Done/Archived task cards to undo landed changes.
category: feature
dev: Wires onRevertTask through Board/List/Detail surfaces; calls POST /tasks/:id/revert in "auto" mode with a conflict-confirm AI-undo fallback (mode: "ai").

View File

@@ -295,7 +295,7 @@ Behavior:
- Renders directed bezier dependency edges (dependent → dependency) with arrowheads
- Supports cursor-centered wheel zoom, pinch zoom, keyboard shortcuts (`Ctrl/Cmd+=`, `Ctrl/Cmd+-`, `Ctrl/Cmd+0`, `Ctrl/Cmd+Shift+F`, `Escape`), and fit/reset controls via the floating toolbar with live zoom percentage
- Pan limits are zoom-aware and based on full graph extents (including negative auto-layout origins), so zoomed-in views can still pan to every rendered node instead of getting trapped by fixed viewport-only bounds
- Dependency graph nodes reuse the same `TaskCard` UI as board/list views, so status badges, progress/steps, mission badges, retry/archive controls, and active-task glow stay visually consistent
- Dependency graph nodes reuse the same `TaskCard` UI as board/list views, so status badges, progress/steps, mission badges, retry/archive/revert controls, and active-task glow stay visually consistent
- Active graph nodes also add a dedicated top status indicator bar and current-step row highlighting so in-progress execution state stays visible even when zoomed out
- Clicking a graph card opens task details in the shared movable/resizable task pop-out via the host detail handler (`onOpenDetail`, with `onOpenTaskDetail` fallback), while clicking the same card again or empty canvas clears selection.
- On touch devices, single-tap is reserved for pan/drag gestures, so double-tapping a node opens the same shared task pop-out; this does not change selection state.

View File

@@ -162,6 +162,16 @@ These appear in task activity history; run-audit entries are emitted where run c
Recovery is reversible: restore archived tasks via dashboard **Unarchive** or `fn_task_unarchive`.
#### Revert/Undo affordance (FN-7525)
Done and Archived task cards (board card inline row + context menu, the detail view, and the list context menu) expose a **Revert** action alongside Archive/Unarchive when the task has a landed commit to revert. Clicking it calls `POST /tasks/:id/revert` in `"auto"` mode:
- A clean git revert shows a success toast naming the created revert commit sha.
- A conflicting/unsupported git result opens a confirm dialog offering to create an AI-undo task; confirming re-calls the route in `"ai"` mode and surfaces the created task id (or that an undo task is already open).
- A `needsHuman` result (e.g. auto-merge is off) is surfaced as an informational/error toast, never silently forked into an AI task.
The source task's column/lifecycle is never mutated as a side effect of a revert; the Revert affordance is absent when the task has no landed commit or when the hosting surface does not support it.
### 2) Plan Mode (AI interview)
On desktop/tablet, open **Planning** from the left sidebar to start or resume a planning session. You can also hand a draft from the board quick-entry row or New Task dialog to Planning with the **Plan** action.

View File

@@ -432,7 +432,7 @@ function AppInner() {
// FNXC:DashboardLiveUpdates 2026-06-26-01:08:
// SSE remains enabled only for board/list views to free connection slots for mission detail fetches. The false→true missed-event catch-up lives inside useTasks so App keeps the routing gate only and cannot double-fetch on task-view re-entry.
const taskSseEnabled = taskView === "board" || taskView === "list";
const { tasks, isStale, createTask, moveTask, pauseTask, unpauseTask, deleteTask, mergeTask, retryTask, resetTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, ingestCreatedTasks, lastFetchTimeMs } = useTasks(
const { tasks, isStale, createTask, moveTask, pauseTask, unpauseTask, deleteTask, mergeTask, retryTask, resetTask, updateTask, duplicateTask, archiveTask, unarchiveTask, revertTask, archiveAllDone, loadArchivedTasks, ingestCreatedTasks, lastFetchTimeMs } = useTasks(
{
...(currentProject ? { projectId: currentProject.id } : {}),
searchQuery: searchQuery || undefined,
@@ -1241,7 +1241,7 @@ function AppInner() {
// Props for the extracted <MainContent> switch (see components/dashboard/MainContent.tsx).
// Every value is passed by its App name; the switch renders the same subtrees as before.
const rightDock = useRightDockController({ active: rightDockActive, projectId: currentProject?.id, addToast, settingsLoaded, researchReadinessVersion, goalAnchorId, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, subscribePluginEvents, openDetailTask, openTaskPopup: popOutTaskDetail, openMobileTasksInPopup, openFileInBrowser, onMoveTask: moveTask, onDeleteTask: deleteTask, onArchiveTask: archiveTask, onMergeTask: mergeTask, onRetryTask: retryTask, onResetTask: resetTask, onDuplicateTask: duplicateTask, onTaskUpdated: (task: Task) => ingestCreatedTasks([task]), openSettings: (section?: string) => openSettingsWithNav(section as SectionId), onOpenUsage: openUsageWithNav, onOpenActivityLog: openActivityLogWithNav, onOpenGitHubImport: openGitHubImportWithNav, onOpenGitManager: openGitManagerWithNav, onOpenSchedules: openSchedulesWithNav, onSendSelectionToTask: modalManager.openNewTaskWithDescription, onCreateTaskFromInsight: handleInsightTaskCreate, onNavigateToMission: handleOpenMission, onTaskCreated: (task: Task) => ingestCreatedTasks([task]), prAuthAvailable, autoMerge, taskDetailChatFirst, visibilityOptions: { experimentalFeatures: { insights: insightsEnabled, memoryView: memoryEnabled, devServerView: devServerEnabled, researchView: researchEnabled, evalsView: evalsEnabled, goalsView: goalsEnabled }, showSkillsTab: skillsEnabled, todosEnabled, pluginDashboardViews }, footerVisible: executorFooterVisible });
const rightDock = useRightDockController({ active: rightDockActive, projectId: currentProject?.id, addToast, settingsLoaded, researchReadinessVersion, goalAnchorId, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, subscribePluginEvents, openDetailTask, openTaskPopup: popOutTaskDetail, openMobileTasksInPopup, openFileInBrowser, onMoveTask: moveTask, onDeleteTask: deleteTask, onArchiveTask: archiveTask, onRevertTask: revertTask, onMergeTask: mergeTask, onRetryTask: retryTask, onResetTask: resetTask, onDuplicateTask: duplicateTask, onTaskUpdated: (task: Task) => ingestCreatedTasks([task]), openSettings: (section?: string) => openSettingsWithNav(section as SectionId), onOpenUsage: openUsageWithNav, onOpenActivityLog: openActivityLogWithNav, onOpenGitHubImport: openGitHubImportWithNav, onOpenGitManager: openGitManagerWithNav, onOpenSchedules: openSchedulesWithNav, onSendSelectionToTask: modalManager.openNewTaskWithDescription, onCreateTaskFromInsight: handleInsightTaskCreate, onNavigateToMission: handleOpenMission, onTaskCreated: (task: Task) => ingestCreatedTasks([task]), prAuthAvailable, autoMerge, taskDetailChatFirst, visibilityOptions: { experimentalFeatures: { insights: insightsEnabled, memoryView: memoryEnabled, devServerView: devServerEnabled, researchView: researchEnabled, evalsView: evalsEnabled, goalsView: goalsEnabled }, showSkillsTab: skillsEnabled, todosEnabled, pluginDashboardViews }, footerVisible: executorFooterVisible });
/*
FNXC:OpenTasksInRightSidebar 2026-06-28-00:00:
@@ -1382,6 +1382,7 @@ function AppInner() {
retryTask,
archiveTask,
unarchiveTask,
revertTask,
deleteTask,
archiveAllDone,
loadArchivedTasks,
@@ -1810,7 +1811,7 @@ function AppInner() {
}}
onPlanningMode={openPlanningWithInitialPlanWithNav}
onSubtaskBreakdown={subtaskBreakdownEnabled ? openSubtaskBreakdownWithNav : undefined}
taskOperations={{ moveTask, deleteTask, mergeTask, archiveTask, retryTask, resetTask, duplicateTask }}
taskOperations={{ moveTask, deleteTask, mergeTask, archiveTask, revertTask, retryTask, resetTask, duplicateTask }}
deepLink={{ handleDetailClose }}
settings={{ prAuthAvailable, autoMerge, taskDetailChatFirst, themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors, resolvedThemeMode, setThemeMode, setColorTheme, setDashboardFontScalePct, setShadcnCustomColors, setQuickChatButtonModeImmediate }}
onSettingsClose={handleSettingsCloseWithNav}

View File

@@ -15,6 +15,7 @@ import {
fetchAgentTasks,
archiveTask,
unarchiveTask,
revertTask,
deleteTask,
ApiRequestError,
moveTask,
@@ -550,6 +551,56 @@ describe("Git Management API", () => {
});
});
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7525):
Client-shape coverage for `revertTask` — the `POST /tasks/:id/revert` route
(FN-7523/FN-7524) contract. Asserts the JSON body is forwarded verbatim and
the parsed discriminated-union result (git or ai path) is returned as-is,
plus standard error propagation on a non-2xx response.
*/
describe("revertTask", () => {
it("sends POST with the JSON mode body and returns the parsed git-path result", async () => {
const gitResult = { mode: "git", clean: true, revertCommitSha: "deadbeef1234" };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, gitResult));
const response = await revertTask("FN-001", undefined, { mode: "auto" });
expect(response).toEqual(gitResult);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/revert", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ mode: "auto" }),
});
});
it("returns the parsed ai-path result", async () => {
const aiResult = { mode: "ai", createdTaskId: "FN-999" };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, aiResult));
const response = await revertTask("FN-001", undefined, { mode: "ai" });
expect(response).toEqual(aiResult);
});
it("defaults to an empty JSON body when no options are provided", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { mode: "git", clean: true }));
await revertTask("FN-001");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/revert", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({}),
});
});
it("throws on error propagation", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Task is in column \"todo\"" }, 409));
await expect(revertTask("FN-001")).rejects.toThrow('Task is in column "todo"');
});
});
describe("deleteTask", () => {
it("sends DELETE to task endpoint", async () => {
const deletedTask: Task = { ...FAKE_DETAIL, column: "done" };

View File

@@ -876,6 +876,57 @@ export function unarchiveTask(id: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/unarchive`, projectId), { method: "POST" });
}
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7525):
Client-side contract for `POST /tasks/:id/revert` (route owned by FN-7523/
FN-7524/FN-7547/FN-7548 — see the `FNXC:TaskRevert` block in
`register-task-workflow-routes.ts`). This is a discriminated union, NOT a
`Task` — the source task's column/status is never mutated by this call; the
caller (useTasks' `revertTask` op) refreshes the task list afterward so any
newly-created revert commit / AI-undo task becomes visible, without patching
the source task's column directly.
*/
export interface RevertTaskWorkspaceRepoResult {
repo: string;
classification?: string;
revertCommitSha?: string;
conflicts?: unknown;
alreadyReverted?: boolean;
}
export interface RevertTaskGitResult {
mode: "git";
clean: boolean;
revertCommitSha?: string;
revertCommitShas?: string[];
conflicts?: unknown;
alreadyReverted?: boolean;
unsupported?: boolean;
needsHuman?: boolean;
reason?: string;
workspace?: { repos: RevertTaskWorkspaceRepoResult[] };
}
export interface RevertTaskAiResult {
mode: "ai";
createdTaskId: string;
alreadyOpen?: boolean;
}
export type RevertTaskResult = RevertTaskGitResult | RevertTaskAiResult;
export interface RevertTaskOptions {
mode?: "git" | "ai" | "auto";
granularity?: "squash" | "per-sha";
}
export function revertTask(id: string, projectId?: string, body?: RevertTaskOptions): Promise<RevertTaskResult> {
return api<RevertTaskResult>(withProjectId(`/tasks/${id}/revert`, projectId), {
method: "POST",
body: JSON.stringify(body ?? {}),
});
}
export function archiveAllDone(projectId?: string): Promise<Task[]> {
return api<{ archived: Task[] }>(withProjectId("/tasks/archive-all-done", projectId), { method: "POST" }).then(
(response) => response.archived

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState, lazy, Suspense } from "react";
import type { ProjectInfo } from "../api";
import type { ProjectInfo, RevertTaskOptions, RevertTaskResult } from "../api";
import type { ColorTheme, Column, MergeResult, Task, TaskCreateInput, ThemeMode, GithubIssueAction } from "@fusion/core";
import type { UseProjectActionsResult } from "../hooks/useProjectActions";
import type { ModalManager } from "../hooks/useModalManager";
@@ -62,6 +62,8 @@ interface AppModalsProps {
}) => Promise<Task>;
mergeTask: (taskId: string) => Promise<MergeResult>;
archiveTask: (taskId: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
/* FNXC:TaskRevert 2026-07-05-00:00 (FN-7525): threaded alongside archiveTask; never mutates the source task's column. */
revertTask?: (taskId: string, body?: RevertTaskOptions) => Promise<RevertTaskResult>;
retryTask: (taskId: string) => Promise<Task>;
resetTask: (taskId: string) => Promise<Task>;
duplicateTask: (taskId: string) => Promise<Task>;
@@ -320,6 +322,7 @@ export function AppModals({
onDeleteTask={taskOperations.deleteTask}
onMergeTask={taskOperations.mergeTask}
onArchiveTask={taskOperations.archiveTask}
onRevertTask={taskOperations.revertTask}
onRetryTask={taskOperations.retryTask}
onResetTask={taskOperations.resetTask}
onDuplicateTask={taskOperations.duplicateTask}

View File

@@ -7,7 +7,7 @@ import "./Board.css";
import type { ToastType } from "../hooks/useToast";
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { promoteTask, type ModelInfo, type BoardWorkflowsPayload, type BoardWorkflowColumn } from "../api";
import { promoteTask, type ModelInfo, type BoardWorkflowsPayload, type BoardWorkflowColumn, type RevertTaskOptions, type RevertTaskResult } from "../api";
import { useBlockerFanout } from "../hooks/useBlockerFanout";
import { MOBILE_MEDIA_QUERY, useViewportMode } from "../hooks/useViewportMode";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
@@ -55,6 +55,8 @@ interface BoardProps {
onRetryTask?: (id: string) => Promise<Task>;
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
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>;
onDeleteTask?: (id: string, options?: {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
@@ -158,7 +160,7 @@ function BoardWorkflowSkeleton({ empty = false }: { empty?: boolean }) {
);
}
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, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, settingsLoaded, 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, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, settingsLoaded, workflowControlsInHeader = false }: BoardProps) {
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
/*
FNXC:DoneColumnSorting 2026-06-29-16:57:
@@ -838,6 +840,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
onRetryTask={onRetryTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onRevertTask={onRevertTask}
onDeleteTask={onDeleteTask}
allTasks={tasks}
availableModels={availableModels}
@@ -920,6 +923,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
onRetryTask={onRetryTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onRevertTask={onRevertTask}
onDeleteTask={onDeleteTask}
availableModels={availableModels}
onOpenDetailWithTab={onOpenDetailWithTab}
@@ -976,6 +980,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
onRetryTask={onRetryTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onRevertTask={onRevertTask}
onDeleteTask={onDeleteTask}
availableModels={availableModels}
onOpenDetailWithTab={onOpenDetailWithTab}
@@ -1027,6 +1032,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
onRetryTask={onRetryTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onRevertTask={onRevertTask}
onDeleteTask={onDeleteTask}
allTasks={tasks}
availableModels={availableModels}

View File

@@ -13,7 +13,7 @@ import { groupByWorktree } from "../utils/worktreeGrouping";
import type { ToastType } from "../hooks/useToast";
import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu";
import { ChevronDown, ChevronUp, MoreVertical } from "lucide-react";
import type { BoardWorkflowDefinition, ModelInfo, BoardWorkflowColumnFlags } from "../api";
import type { BoardWorkflowDefinition, ModelInfo, BoardWorkflowColumnFlags, RevertTaskOptions, RevertTaskResult } from "../api";
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
import type { DoneColumnSortMode } from "./taskSorting";
@@ -120,6 +120,8 @@ interface ColumnProps {
onRetryTask?: (id: string) => Promise<Task>;
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
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>;
onDeleteTask?: (id: string, options?: {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
@@ -197,7 +199,7 @@ interface ColumnProps {
getDraggingTaskId?: () => string | null;
}
function ColumnComponent({ column, 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, onDeleteTask, onArchiveAllDone, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, workflowOptions, defaultWorkflowId, columnDisplayName, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
function ColumnComponent({ column, 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, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, workflowOptions, defaultWorkflowId, columnDisplayName, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
const { t } = useTranslation("app");
// Anchor the board.rejection.* catalog keys for the i18next extractor (it
// scopes `t` to the useTranslation binding, so the shared translateRejection
@@ -850,6 +852,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
onMergeTask={onMergeTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onRevertTask={onRevertTask}
onDeleteTask={onDeleteTask}
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}
@@ -890,6 +893,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
onMergeTask={onMergeTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onRevertTask={onRevertTask}
onDeleteTask={onDeleteTask}
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}

View File

@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import type { Task, TaskDetail, Column as ColumnType, ColumnId, TaskCreateInput, GithubIssueAction } from "@fusion/core";
import { Column } from "./Column";
import { sortTasksForDisplayColumn } from "./taskSorting";
import type { ModelInfo, BoardWorkflowDefinition } from "../api";
import type { ModelInfo, BoardWorkflowDefinition, RevertTaskOptions, RevertTaskResult } from "../api";
import type { ToastType } from "../hooks/useToast";
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
@@ -50,6 +50,8 @@ export interface LaneProps {
onRetryTask?: (id: string) => Promise<Task>;
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
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>;
onDeleteTask?: (id: string, options?: {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
@@ -199,6 +201,7 @@ function LaneComponent(props: LaneProps) {
onRetryTask={props.onRetryTask}
onArchiveTask={props.onArchiveTask}
onUnarchiveTask={props.onUnarchiveTask}
onRevertTask={props.onRevertTask}
onDeleteTask={props.onDeleteTask}
availableModels={props.availableModels}
onOpenDetailWithTab={props.onOpenDetailWithTab}

View File

@@ -12,7 +12,7 @@ import { sortTasksForDisplayColumn } from "./taskSorting";
import { batchUpdateTaskModels, fetchNodes, fetchTaskDetail, rebuildTaskSpec, refreshPrStatus, updateTask } from "../api";
import { TaskDetailContent } from "./TaskDetailModal";
import { PrCreateModal } from "./PrCreateModal";
import type { BoardWorkflowColumn, BoardWorkflowsPayload, ModelInfo, NodeInfo } from "../api";
import type { BoardWorkflowColumn, BoardWorkflowsPayload, ModelInfo, NodeInfo, RevertTaskOptions, RevertTaskResult } from "../api";
import { QuickEntryBox } from "./QuickEntryBox";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { NodeHealthDot } from "./NodeHealthDot";
@@ -221,6 +221,8 @@ interface ListViewProps {
onPauseTask?: (id: string) => Promise<Task>;
onUnpauseTask?: (id: string) => Promise<Task>;
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
/* FNXC:TaskRevert 2026-07-05-00:00 (FN-7525): threaded alongside onArchiveTask; never mutates the source task's column. */
onRevertTask?: (id: string, body?: RevertTaskOptions) => Promise<RevertTaskResult>;
onMergeTask: (id: string) => Promise<MergeResult>;
onResetTask?: (id: string) => Promise<Task>;
onDuplicateTask?: (id: string) => Promise<Task>;
@@ -313,6 +315,7 @@ export function ListView({
onPauseTask,
onUnpauseTask,
onArchiveTask,
onRevertTask,
onMergeTask,
onResetTask,
onDuplicateTask,
@@ -1532,6 +1535,64 @@ export function ListView({
}
}, [addToast, confirm, onArchiveTask, t]);
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7525):
List-view Revert action, mirroring TaskCard's `handleRevertClick`: auto mode
first, clean-git success toast with the revert commit sha, an info toast for
`alreadyReverted`, an error toast (never a silent AI fork) for `needsHuman`,
and a confirm-gated AI-undo fallback on conflict/unsupported. The source
task's column is never mutated as a side effect.
*/
const handleListTaskRevert = useCallback(async (task: Task) => {
if (!onRevertTask) return;
try {
const result = await onRevertTask(task.id, { mode: "auto" });
if (result.mode === "ai") {
addToast(result.alreadyOpen
? t("tasks.revertAlreadyOpen", "An undo task is already open: {{id}}", { id: result.createdTaskId })
: t("tasks.revertAiCreated", "Created undo task {{id}}", { id: result.createdTaskId }), "success");
return;
}
if (result.alreadyReverted) {
addToast(t("tasks.revertAlreadyReverted", "{{taskId}} was already reverted", { taskId: task.id }), "info");
return;
}
if (result.needsHuman) {
addToast(t("tasks.revertNeedsHuman", "Cannot auto-revert {{taskId}}: {{reason}}", { taskId: task.id, reason: result.reason || t("tasks.revertNeedsHumanDefault", "human review required") }), "error");
return;
}
if (result.clean && result.revertCommitSha) {
addToast(t("tasks.reverted", "Reverted {{taskId}} in commit {{sha}}", { taskId: task.id, sha: result.revertCommitSha.slice(0, 12) }), "success");
return;
}
if (!result.clean || result.unsupported) {
const confirmed = await confirm({
title: t("tasks.revertConflictTitle", "Revert Conflict"),
message: t("tasks.revertConflictMessage", "Git revert conflicts with later changes. Create an AI task to undo this?"),
cancelLabel: t("common.cancel", "Cancel"),
});
if (!confirmed) return;
const aiResult = await onRevertTask(task.id, { mode: "ai" });
if (aiResult.mode === "ai") {
addToast(aiResult.alreadyOpen
? t("tasks.revertAlreadyOpen", "An undo task is already open: {{id}}", { id: aiResult.createdTaskId })
: t("tasks.revertAiCreated", "Created undo task {{id}}", { id: aiResult.createdTaskId }), "success");
}
return;
}
addToast(t("tasks.revertFailed", "Failed to revert {{taskId}}", { taskId: task.id }), "error");
} catch (err) {
addToast(getErrorMessage(err), "error");
}
}, [addToast, confirm, onRevertTask, t]);
const handleListContextMove = useCallback(async (task: Task, column: ColumnId) => {
try {
const hasStepProgress = task.steps.some((step) => step.status !== "pending");
@@ -1704,6 +1765,21 @@ export function ListView({
if (task.column === "done" && onArchiveTask) {
actions.push({ id: "archive", label: t("tasks.archive", "Archive"), onSelect: () => void handleListTaskArchive(task) });
}
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7525):
List-view Revert menu entry for done/archived rows, mirroring the `archive`
entry above. Disabled (rather than omitted) when the task lacks a landed
commit to revert.
*/
if ((task.column === "done" || task.column === "archived") && onRevertTask) {
const isRevertable = Boolean(task.mergeDetails?.commitSha);
actions.push({
id: "revert",
label: t("tasks.revert", "Revert"),
disabled: !isRevertable,
onSelect: isRevertable ? () => void handleListTaskRevert(task) : undefined,
});
}
for (const transition of model.moveTransitions) {
actions.push({
id: `move-${transition.column}`,
@@ -1715,7 +1791,7 @@ export function ListView({
actions.push({ id: model.reviewAction.id, label: model.reviewAction.label, disabled: model.reviewAction.disabled, onSelect: model.reviewAction.onSelect });
}
return actions.filter((action) => action.tone === "note" || action.disabled === true || Boolean(action.onSelect));
}, [addToast, autoMerge, columnFlagsById, confirm, getListColumnLabel, handleListContextCheckPrStatus, handleListContextEnableGithubTracking, handleListContextMove, handleListTaskArchive, handleListTaskDelete, isMobile, listContextMenuColumns, mergeStrategy, onDuplicateTask, onMergeTask, onOpenDetail, onPauseTask, onResetTask, onRetryTask, onUnpauseTask, onArchiveTask, onTasksUpdated, projectId, t]);
}, [addToast, autoMerge, columnFlagsById, confirm, getListColumnLabel, handleListContextCheckPrStatus, handleListContextEnableGithubTracking, handleListContextMove, handleListTaskArchive, handleListTaskDelete, handleListTaskRevert, isMobile, listContextMenuColumns, mergeStrategy, onDuplicateTask, onMergeTask, onOpenDetail, onPauseTask, onResetTask, onRetryTask, onUnpauseTask, onArchiveTask, onRevertTask, onTasksUpdated, projectId, t]);
const contextMenuActions = useMemo(
() => (contextMenuState ? buildListContextMenuActions(contextMenuState.task) : []),

View File

@@ -1446,9 +1446,17 @@ executing. These map 1:1 to the unified progress status so the dot color encodes
opacity: 1;
}
/* Archive/Unarchive buttons */
/* Archive/Unarchive/Revert buttons */
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7525):
.card-revert-btn shares the .card-archive-btn/.card-unarchive-btn tokenized
look (no one-off px/hex) — it's applied alongside .card-archive-btn on the
markup so no separate ruleset was strictly required, but it's declared here
too so future divergence doesn't have to rediscover the shared selector group.
*/
.card-archive-btn,
.card-unarchive-btn {
.card-unarchive-btn,
.card-revert-btn {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -1468,19 +1476,22 @@ executing. These map 1:1 to the unified progress status so the dot color encodes
}
.card:hover .card-archive-btn,
.card:hover .card-unarchive-btn {
.card:hover .card-unarchive-btn,
.card:hover .card-revert-btn {
opacity: 1;
}
.card-archive-btn:hover,
.card-unarchive-btn:hover {
.card-unarchive-btn:hover,
.card-revert-btn:hover {
background: var(--card-hover);
color: var(--text);
border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%);
}
.card-archive-btn:focus,
.card-unarchive-btn:focus {
.card-unarchive-btn:focus,
.card-revert-btn:focus {
opacity: 1;
outline: 1px solid var(--todo);
outline-offset: 1px;
@@ -1658,7 +1669,8 @@ executing. These map 1:1 to the unified progress status so the dot color encodes
/* FN-4351/FN-3965: keep secondary actions visible on touch, but compact per WCAG 2.5.8 because the card tap surface is the primary target for opening task detail. */
.card-archive-btn,
.card-unarchive-btn {
.card-unarchive-btn,
.card-revert-btn {
opacity: 1;
}

View File

@@ -20,7 +20,7 @@ import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
// resolver — like resolveEffectiveAutoMerge above — must be imported from its source module
// directly rather than the package barrel.
import { resolveEffectivePlannerOversightLevel } from "../../../core/src/workflow-settings-resolver";
import { addressPrFeedback, fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent, rebuildTaskSpec, refreshPrStatus, fetchWorkflowSettingValues, type WorkflowFieldDefinition } from "../api";
import { addressPrFeedback, fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent, rebuildTaskSpec, refreshPrStatus, fetchWorkflowSettingValues, type WorkflowFieldDefinition, type RevertTaskOptions, type RevertTaskResult } from "../api";
import { GitHubBadge } from "./GitHubBadge";
import { GitLabBadge } from "./GitLabBadge";
import { PrCreateModal } from "./PrCreateModal";
@@ -489,6 +489,14 @@ interface TaskCardProps {
) => Promise<Task>;
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7525):
Threaded alongside onArchiveTask/onUnarchiveTask; the source task's column
is never mutated by the caller as a side effect. Absent when the parent
does not support revert (undefined -> no button rendered, mirroring the
onArchiveTask guard).
*/
onRevertTask?: (id: string, body?: RevertTaskOptions) => Promise<RevertTaskResult>;
onDeleteTask?: (id: string, options?: {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
@@ -710,6 +718,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previous.onUpdateTask === next.onUpdateTask &&
previous.onArchiveTask === next.onArchiveTask &&
previous.onUnarchiveTask === next.onUnarchiveTask &&
previous.onRevertTask === next.onRevertTask &&
previous.onDeleteTask === next.onDeleteTask &&
previous.onPauseTask === next.onPauseTask &&
previous.onRetryTask === next.onRetryTask &&
@@ -851,6 +860,7 @@ function TaskCardComponent({
onUpdateTask,
onArchiveTask,
onUnarchiveTask,
onRevertTask,
onDeleteTask,
onPauseTask,
onRetryTask,
@@ -1861,6 +1871,83 @@ function TaskCardComponent({
});
}, [addToast, onUnarchiveTask, task.id]);
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7525):
Revertable guard: a card is only offered a Revert affordance when it sits in
done/archived AND it has a landed commit to revert. Absent `mergeDetails` (no
merge ever recorded, e.g. a no-op/no-commits-expected task) means there is
nothing to revert — treat it as not-revertable rather than erroring at click
time. This mirrors the parent FN-7501 issue's "undo a change" framing: only
tasks that actually changed the tree are revertable.
*/
const isRevertable = (task.column === "done" || task.column === "archived")
&& Boolean(task.mergeDetails?.commitSha);
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7525):
Revert click handler: calls the API in "auto" mode (git-first, AI-undo
fallback on conflict/unsupported). Never silently AI-forks a `needsHuman`
result (e.g. autoMerge-off) — that is surfaced as an informational toast so a
human can decide, per the FN-7524 route contract. The SOURCE task's column is
never mutated here as a side effect of a revert.
*/
const handleRevertClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (!onRevertTask) return;
void onRevertTask(task.id, { mode: "auto" }).then(async (result) => {
if (result.mode === "ai") {
addToast(result.alreadyOpen
? t("tasks.revertAlreadyOpen", "An undo task is already open: {{id}}", { id: result.createdTaskId })
: t("tasks.revertAiCreated", "Created undo task {{id}}", { id: result.createdTaskId }), "success");
return;
}
if (result.alreadyReverted) {
addToast(t("tasks.revertAlreadyReverted", "{{taskId}} was already reverted", { taskId: task.id }), "info");
return;
}
if (result.needsHuman) {
addToast(t("tasks.revertNeedsHuman", "Cannot auto-revert {{taskId}}: {{reason}}", { taskId: task.id, reason: result.reason || t("tasks.revertNeedsHumanDefault", "human review required") }), "error");
return;
}
if (result.clean && result.revertCommitSha) {
addToast(t("tasks.reverted", "Reverted {{taskId}} in commit {{sha}}", { taskId: task.id, sha: result.revertCommitSha.slice(0, 12) }), "success");
return;
}
if (!result.clean || result.unsupported) {
const confirmed = await confirm({
title: t("tasks.revertConflictTitle", "Revert Conflict"),
message: t("tasks.revertConflictMessage", "Git revert conflicts with later changes. Create an AI task to undo this?"),
});
if (!confirmed) return;
try {
const aiResult = await onRevertTask(task.id, { mode: "ai" });
if (aiResult.mode === "ai") {
addToast(aiResult.alreadyOpen
? t("tasks.revertAlreadyOpen", "An undo task is already open: {{id}}", { id: aiResult.createdTaskId })
: t("tasks.revertAiCreated", "Created undo task {{id}}", { id: aiResult.createdTaskId }), "success");
}
} catch (aiErr) {
addToast(getErrorMessage(aiErr), "error");
}
return;
}
addToast(t("tasks.revertFailed", "Failed to revert {{taskId}}", { taskId: task.id }), "error");
}).catch((err) => {
addToast(getErrorMessage(err), "error");
});
}, [addToast, confirm, onRevertTask, t, task.id]);
const handleTaskActionRevert = useCallback(() => {
handleRevertClick({ stopPropagation() {} } as React.MouseEvent<HTMLButtonElement>);
}, [handleRevertClick]);
const handleDeleteClick = useCallback(async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (!onDeleteTask) return;
@@ -2244,7 +2331,7 @@ function TaskCardComponent({
task.prInfo,
]);
const contextMenuActions = useMemo<TaskMenuActionDescriptor[]>(() => {
if (!onDeleteTask && !onArchiveTask && !onUnarchiveTask && !onDuplicateTask && !onRetryTask && !onResetTask && !onPauseTask && !onUnpauseTask && !onMergeTask && !onMoveTask && !onOpenRefine && !onUpdateTask) {
if (!onDeleteTask && !onArchiveTask && !onUnarchiveTask && !onRevertTask && !onDuplicateTask && !onRetryTask && !onResetTask && !onPauseTask && !onUnpauseTask && !onMergeTask && !onMoveTask && !onOpenRefine && !onUpdateTask) {
return [];
}
const actions = [...taskActionMenuModel.actions];
@@ -2254,6 +2341,21 @@ function TaskCardComponent({
if (task.column === "archived" && onUnarchiveTask) {
actions.push({ id: "unarchive", label: t("tasks.unarchive", "Unarchive"), onSelect: handleTaskActionUnarchive });
}
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7525):
Context-menu Revert entry for done/archived, mirroring the archive/unarchive
entries above. Disabled (rather than omitted) when the task lacks a landed
commit to revert, so the menu communicates WHY the affordance is inert
instead of silently hiding it.
*/
if ((task.column === "done" || task.column === "archived") && onRevertTask) {
actions.push({
id: "revert",
label: t("tasks.revert", "Revert"),
disabled: !isRevertable,
onSelect: isRevertable ? handleTaskActionRevert : undefined,
});
}
if (taskActionMenuModel.reviewAction) {
actions.push({ id: taskActionMenuModel.reviewAction.id, label: taskActionMenuModel.reviewAction.label, disabled: taskActionMenuModel.reviewAction.disabled, onSelect: taskActionMenuModel.reviewAction.onSelect });
}
@@ -2267,7 +2369,7 @@ function TaskCardComponent({
}
}
return actions.filter((action) => action.tone === "note" || action.disabled === true || Boolean(action.onSelect));
}, [handleTaskActionArchive, handleTaskActionMove, handleTaskActionUnarchive, onArchiveTask, onDeleteTask, onDuplicateTask, onMergeTask, onMoveTask, onOpenRefine, onPauseTask, onResetTask, onRetryTask, onUnarchiveTask, onUnpauseTask, onUpdateTask, t, task.column, taskActionMenuModel.actions, taskActionMenuModel.moveTransitions, taskActionMenuModel.reviewAction]);
}, [handleTaskActionArchive, handleTaskActionMove, handleTaskActionRevert, handleTaskActionUnarchive, isRevertable, onArchiveTask, onDeleteTask, onDuplicateTask, onMergeTask, onMoveTask, onOpenRefine, onPauseTask, onResetTask, onRetryTask, onRevertTask, onUnarchiveTask, onUnpauseTask, onUpdateTask, t, task.column, taskActionMenuModel.actions, taskActionMenuModel.moveTransitions, taskActionMenuModel.reviewAction]);
const hasContextMenuActions = contextMenuActions.length > 0;
const closeContextMenu = useCallback(() => {
@@ -2970,6 +3072,25 @@ function TaskCardComponent({
{t("tasks.unarchive", "Unarchive")}
</button>
)}
{/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7525):
Inline Revert affordance for done/archived cards (parent FN-7501). Rendered
only when the task actually has a landed commit to revert (`isRevertable`)
— omitted (not disabled) here to avoid an empty button shell on cards with
nothing to revert, matching the "omit inline / disable in menu" split called
out in the task spec. Reuses `card-archive-btn`'s tokenized styling via a
shared class so no new one-off CSS/colors are introduced.
*/}
{(task.column === "done" || task.column === "archived") && onRevertTask && isRevertable && (
<button
className="card-archive-btn card-revert-btn"
onClick={handleRevertClick}
title={t("tasks.revertTask", "Revert this task's changes")}
aria-label={t("tasks.revertTask", "Revert this task's changes")}
>
{t("tasks.revert", "Revert")}
</button>
)}
{task.column === "in-progress" && onMoveTask && (
<div className="card-send-back" ref={sendBackRef}>
<button

View File

@@ -24,6 +24,7 @@ import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplica
import { getRevertOfId, findOpenUndoTaskForSource } from "../utils/taskRevert";
import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
import { uploadAttachment, deleteAttachment, updateTask, repairOverlapBlocker, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchTaskEffectiveSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, fetchWorkflowSettingValues, nudgeOverseer, stopOverseer, explainOverseer, api } from "../api";
import type { RevertTaskOptions, RevertTaskResult } from "../api";
import type { BoardWorkflowsPayload, WorkflowFieldDefinition, CustomFieldRejection } from "../api";
import { WorkflowIcon } from "./WorkflowIcon";
import { ApiRequestError } from "../api";
@@ -347,6 +348,8 @@ export interface TaskDetailModalProps {
allowResurrection?: boolean;
}) => Promise<Task>;
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
/* FNXC:TaskRevert 2026-07-05-00:00 (FN-7525): threaded alongside onArchiveTask; never mutates the source task's column. */
onRevertTask?: (id: string, body?: RevertTaskOptions) => Promise<RevertTaskResult>;
onMergeTask: (id: string) => Promise<MergeResult>;
onRetryTask?: (id: string) => Promise<Task>;
onResetTask?: (id: string) => Promise<Task>;
@@ -615,6 +618,7 @@ export function TaskDetailContent({
onMoveTask,
onDeleteTask,
onArchiveTask,
onRevertTask,
onMergeTask,
onRetryTask,
onResetTask,
@@ -2585,6 +2589,68 @@ export function TaskDetailContent({
}
}, [onArchiveTask, confirm, task.id, nearDuplicateOf, addToast, requestClose]);
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7525):
Detail-view Revert action, mirroring TaskCard's `handleRevertClick`: calls the
API in "auto" mode, surfaces a clean-git success toast with the revert commit
sha, an info toast for `alreadyReverted`, an error toast (never a silent AI
fork) for `needsHuman`, and otherwise confirms before falling back to the
AI-undo task on conflict/unsupported. The source task's column is never
mutated as a side effect.
*/
const isRevertable = (task.column === "done" || task.column === "archived")
&& Boolean(task.mergeDetails?.commitSha);
const handleRevertTask = useCallback(async () => {
if (!onRevertTask) return;
try {
const result = await onRevertTask(task.id, { mode: "auto" });
if (result.mode === "ai") {
addToast(result.alreadyOpen
? t("tasks.revertAlreadyOpen", "An undo task is already open: {{id}}", { id: result.createdTaskId })
: t("tasks.revertAiCreated", "Created undo task {{id}}", { id: result.createdTaskId }), "success");
return;
}
if (result.alreadyReverted) {
addToast(t("tasks.revertAlreadyReverted", "{{taskId}} was already reverted", { taskId: task.id }), "info");
return;
}
if (result.needsHuman) {
addToast(t("tasks.revertNeedsHuman", "Cannot auto-revert {{taskId}}: {{reason}}", { taskId: task.id, reason: result.reason || t("tasks.revertNeedsHumanDefault", "human review required") }), "error");
return;
}
if (result.clean && result.revertCommitSha) {
addToast(t("tasks.reverted", "Reverted {{taskId}} in commit {{sha}}", { taskId: task.id, sha: result.revertCommitSha.slice(0, 12) }), "success");
return;
}
if (!result.clean || result.unsupported) {
const confirmed = await confirm({
title: t("tasks.revertConflictTitle", "Revert Conflict"),
message: t("tasks.revertConflictMessage", "Git revert conflicts with later changes. Create an AI task to undo this?"),
cancelLabel: t("common.cancel", "Cancel"),
});
if (!confirmed) return;
const aiResult = await onRevertTask(task.id, { mode: "ai" });
if (aiResult.mode === "ai") {
addToast(aiResult.alreadyOpen
? t("tasks.revertAlreadyOpen", "An undo task is already open: {{id}}", { id: aiResult.createdTaskId })
: t("tasks.revertAiCreated", "Created undo task {{id}}", { id: aiResult.createdTaskId }), "success");
}
return;
}
addToast(t("tasks.revertFailed", "Failed to revert {{taskId}}", { taskId: task.id }), "error");
} catch (err) {
addToast(getErrorMessage(err), "error");
}
}, [onRevertTask, confirm, task.id, addToast, t]);
const isTaskPaused = task.paused || task.userPaused;
/*
* FNXC:PlanApproval 2026-07-04-21:35:
@@ -5682,6 +5748,25 @@ export function TaskDetailContent({
</button>
)}
{/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7525):
Detail-view Revert button for done/archived tasks, mirroring the
standalone triage Delete button above. Rendered (not just menu-only)
because the detail view is the primary surface for reviewing a
completed task's outcome. Omitted — not disabled — when the task has
no landed commit to revert, avoiding an empty button shell.
*/}
{(task.column === "done" || task.column === "archived") && onRevertTask && isRevertable && (
<button
className="btn btn-sm"
onClick={() => void handleRevertTask()}
aria-label={t("tasks.revertTask", "Revert this task's changes")}
title={t("tasks.revertTask", "Revert this task's changes")}
>
{t("tasks.revert", "Revert")}
</button>
)}
{/* Actions dropdown — less common operations */}
{taskActionMenuModel.shouldShowActionsMenu && (
<div className="detail-actions-dropdown" ref={actionsMenuRef}>

View File

@@ -5,6 +5,7 @@ import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplica
import { ClipboardList, GitBranch } from "lucide-react";
import { TaskCard } from "./TaskCard";
import type { ToastType } from "../hooks/useToast";
import type { RevertTaskOptions, RevertTaskResult } from "../api";
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu";
@@ -31,6 +32,8 @@ interface WorktreeGroupProps {
onMergeTask?: (id: string) => Promise<MergeResult>;
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
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>;
onDeleteTask?: (id: string, options?: {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
@@ -81,6 +84,7 @@ function WorktreeGroupComponent({
onMergeTask,
onArchiveTask,
onUnarchiveTask,
onRevertTask,
onDeleteTask,
onOpenDetailWithTab,
taskStuckTimeoutMs,
@@ -135,6 +139,7 @@ function WorktreeGroupComponent({
onMergeTask={onMergeTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onRevertTask={onRevertTask}
onDeleteTask={onDeleteTask}
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}
@@ -171,6 +176,7 @@ function WorktreeGroupComponent({
onMergeTask={onMergeTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onRevertTask={onRevertTask}
onDeleteTask={onDeleteTask}
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}

View File

@@ -1132,6 +1132,157 @@ describe("TaskCard", () => {
expect(screen.getByLabelText("Unarchive task")).toBeDefined();
});
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7525):
Coverage for the Revert affordance: presence/absence on done + archived
cards (inline row + context menu), the disabled/omitted no-commit-to-revert
guard, the auto→clean-success path, and the auto→conflict→confirm→AI-undo
fallback path.
*/
describe("Revert affordance", () => {
it("renders the inline Revert button for a done card with a landed commit", () => {
render(
<TaskCard
task={makeTask({ column: "done", mergeDetails: { commitSha: "abc123def456" } as any })}
onOpenDetail={noop}
addToast={noop}
onRevertTask={vi.fn(async () => ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)}
/>,
);
expect(screen.getByLabelText("Revert this task's changes")).toBeDefined();
});
it("renders the inline Revert button for an archived card with a landed commit", () => {
render(
<TaskCard
task={makeTask({ column: "archived", mergeDetails: { commitSha: "abc123def456" } as any })}
onOpenDetail={noop}
addToast={noop}
onRevertTask={vi.fn(async () => ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)}
/>,
);
expect(screen.getByLabelText("Revert this task's changes")).toBeDefined();
});
it("omits the Revert button when onRevertTask is not provided", () => {
render(
<TaskCard
task={makeTask({ column: "done", mergeDetails: { commitSha: "abc123def456" } as any })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByLabelText("Revert this task's changes")).toBeNull();
});
it("omits the inline Revert button when the task has no landed commit", () => {
render(
<TaskCard
task={makeTask({ column: "done", mergeDetails: undefined })}
onOpenDetail={noop}
addToast={noop}
onRevertTask={vi.fn(async () => ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)}
/>,
);
expect(screen.queryByLabelText("Revert this task's changes")).toBeNull();
});
it("shows a disabled Revert context-menu entry when the task has no landed commit", () => {
render(
<TaskCard
task={makeTask({ column: "done", mergeDetails: undefined })}
onOpenDetail={noop}
addToast={noop}
onRevertTask={vi.fn(async () => ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)}
/>,
);
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
const menuItem = screen.getByRole("menuitem", { name: "Revert" });
expect(menuItem).toBeDisabled();
});
it("shows the Revert context-menu entry for done and archived cards", () => {
render(
<TaskCard
task={makeTask({ column: "done", mergeDetails: { commitSha: "abc123def456" } as any })}
onOpenDetail={noop}
addToast={noop}
onRevertTask={vi.fn(async () => ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)}
/>,
);
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
expect(screen.getByRole("menuitem", { name: "Revert" })).toBeDefined();
});
it("calls onRevertTask in auto mode and toasts the revert commit sha on a clean result", async () => {
const addToast = vi.fn();
const onRevertTask = vi.fn(async () => ({ mode: "git", clean: true, revertCommitSha: "deadbeef1234" }) as any);
render(
<TaskCard
task={makeTask({ column: "done", mergeDetails: { commitSha: "abc123def456" } as any })}
onOpenDetail={noop}
addToast={addToast}
onRevertTask={onRevertTask}
/>,
);
await act(async () => {
fireEvent.click(screen.getByLabelText("Revert this task's changes"));
});
await waitFor(() => {
expect(onRevertTask).toHaveBeenCalledWith("FN-001", { mode: "auto" });
});
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("deadbeef1234"),
"success",
);
});
});
it("opens a confirm dialog on conflict and falls back to mode: ai, surfacing the created task id", async () => {
const addToast = vi.fn();
const onRevertTask = vi.fn()
.mockResolvedValueOnce({ mode: "git", clean: false, conflicts: [{}] } as any)
.mockResolvedValueOnce({ mode: "ai", createdTaskId: "FN-999" } as any);
mockConfirm.mockResolvedValueOnce(true);
render(
<TaskCard
task={makeTask({ column: "done", mergeDetails: { commitSha: "abc123def456" } as any })}
onOpenDetail={noop}
addToast={addToast}
onRevertTask={onRevertTask}
/>,
);
await act(async () => {
fireEvent.click(screen.getByLabelText("Revert this task's changes"));
});
await waitFor(() => {
expect(mockConfirm).toHaveBeenCalled();
});
await waitFor(() => {
expect(onRevertTask).toHaveBeenNthCalledWith(2, "FN-001", { mode: "ai" });
});
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("FN-999"),
"success",
);
});
});
});
it("keeps two-button delete flow for non-done task", async () => {
const onDeleteTask = vi.fn(async () => makeTask());
mockConfirm.mockResolvedValueOnce(false);

View File

@@ -418,6 +418,69 @@ describe("TaskCard mobile", () => {
expectRuleToContain(mobileSection, ".card-delete-btn", "height: 28px;");
});
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7525):
Mobile coverage for the Revert affordance (FN-5893 Surface Enumeration —
mobile breakpoint): the button renders on done/archived cards at mobile
width and, critically, leaves NO empty/orphaned button shell when it is
hidden (no landed commit, or onRevertTask undefined).
*/
it("sets .card-revert-btn opacity: 1 in the mobile media block alongside archive/unarchive", () => {
const css = loadAllAppCss();
const mobileSection = getMainMobileSection(css);
expectRuleToContain(mobileSection, ".card-revert-btn", "opacity: 1;");
});
it("renders the Revert affordance on a done card at the mobile breakpoint", () => {
const task = createTask({ id: "FN-201", column: "done", mergeDetails: { commitSha: "abc123def456" } as any });
const { container } = render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={vi.fn()}
onRevertTask={vi.fn(async () => ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)}
/>,
);
expect(container.querySelector(".card-revert-btn")).toBeTruthy();
});
it("renders the Revert affordance on an archived card at the mobile breakpoint", () => {
const task = createTask({ id: "FN-202", column: "archived", mergeDetails: { commitSha: "abc123def456" } as any });
const { container } = render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={vi.fn()}
onRevertTask={vi.fn(async () => ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)}
/>,
);
expect(container.querySelector(".card-revert-btn")).toBeTruthy();
});
it("leaves no empty/orphaned Revert button shell when not revertable or onRevertTask is undefined", () => {
const notRevertableTask = createTask({ id: "FN-203", column: "done", mergeDetails: undefined });
const { container: containerA } = render(
<TaskCard
task={notRevertableTask}
onOpenDetail={vi.fn()}
addToast={vi.fn()}
onRevertTask={vi.fn(async () => ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)}
/>,
);
expect(containerA.querySelector(".card-revert-btn")).toBeNull();
const revertableTask = createTask({ id: "FN-204", column: "done", mergeDetails: { commitSha: "abc123def456" } as any });
const { container: containerB } = render(
<TaskCard task={revertableTask} onOpenDetail={vi.fn()} addToast={vi.fn()} />,
);
expect(containerB.querySelector(".card-revert-btn")).toBeNull();
});
it("opens task detail on quick tap", async () => {
const task = createTask({ id: "FN-200", column: "todo" });

View File

@@ -130,6 +130,7 @@ export function MainContent({
retryTask,
archiveTask,
unarchiveTask,
revertTask,
deleteTask,
archiveAllDone,
loadArchivedTasks,
@@ -723,6 +724,7 @@ export function MainContent({
onMergeTask={mergeTask}
onArchiveTask={archiveTask}
onUnarchiveTask={unarchiveTask}
onRevertTask={revertTask}
onDeleteTask={deleteTask}
onArchiveAllDone={archiveAllDone}
onLoadArchivedTasks={loadArchivedTasks}
@@ -828,6 +830,7 @@ export function MainContent({
onMergeTask={mergeTask}
onArchiveTask={archiveTask}
onUnarchiveTask={unarchiveTask}
onRevertTask={revertTask}
onDeleteTask={deleteTask}
onArchiveAllDone={archiveAllDone}
onLoadArchivedTasks={loadArchivedTasks}
@@ -865,6 +868,7 @@ export function MainContent({
onPauseTask={pauseTask}
onUnpauseTask={unpauseTask}
onArchiveTask={archiveTask}
onRevertTask={revertTask}
onMergeTask={mergeTask}
onResetTask={resetTask}
onDuplicateTask={duplicateTask}

View File

@@ -27,6 +27,8 @@ import type {
NodeInfo,
ProjectInfo,
ProjectInfoWithSource,
RevertTaskOptions,
RevertTaskResult,
} from "../../api";
import type { FusionShellApi } from "../../types/native-shell";
import type { DetailTaskOpenOptions, DetailTaskTab, ModalManager } from "../../hooks/useModalManager";
@@ -177,6 +179,12 @@ export interface MainContentProps {
retryTask: (id: string) => Promise<Task>;
archiveTask: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
unarchiveTask: (id: string) => Promise<Task>;
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7525):
Threaded alongside archiveTask/unarchiveTask; never mutates the source
task's column as a side effect (see route + client contract comments).
*/
revertTask: (id: string, body?: RevertTaskOptions) => Promise<RevertTaskResult>;
deleteTask: (
id: string,
options?: {

View File

@@ -4,6 +4,7 @@ import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplica
import type { ToastType } from "../hooks/useToast";
import type { DetailTaskTab } from "../hooks/useModalManager";
import { fetchTaskDetail } from "../api";
import type { RevertTaskOptions, RevertTaskResult } from "../api";
import { getScopedItem } from "../utils/projectStorage";
import { DOCK_FILES_CURRENT_KEY } from "./DockFilesView";
import { TaskCard } from "./TaskCard";
@@ -29,6 +30,8 @@ export interface RightDockControllerInput {
onMoveTask: (id: string, column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
onDeleteTask: (id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; githubIssueAction?: GithubIssueAction; allowResurrection?: boolean }) => Promise<Task>;
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
/* FNXC:TaskRevert 2026-07-05-00:00 (FN-7525): threaded alongside onArchiveTask; never mutates the source task's column. */
onRevertTask?: (id: string, body?: RevertTaskOptions) => Promise<RevertTaskResult>;
onMergeTask: (id: string) => Promise<MergeResult>;
onRetryTask?: (id: string) => Promise<Task>;
onResetTask?: (id: string) => Promise<Task>;
@@ -233,6 +236,7 @@ export function useRightDockController(input: RightDockControllerInput): RightDo
onMoveTask={input.onMoveTask}
onDeleteTask={input.onDeleteTask}
onArchiveTask={input.onArchiveTask}
onRevertTask={input.onRevertTask}
onMergeTask={input.onMergeTask}
onRetryTask={input.onRetryTask}
onResetTask={input.onResetTask}

View File

@@ -751,6 +751,25 @@ export function useTasks(options?: UseTasksOptions) {
return task;
}, [projectId]);
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7525):
Client-side `revertTask` op. Deliberately does NOT patch the source task's
column/status in local state — the git/AI-undo route never moves the
source task backward (see the `FNXC:TaskRevert` route contract). On success
(either a clean git revert producing a new commit, or an AI-undo task being
created) we re-fetch via `refreshTasksRef` so the board picks up the new
AI-undo task / any lineage changes without us guessing at the shape of the
update ourselves.
*/
const revertTask = useCallback(async (
id: string,
body?: api.RevertTaskOptions,
): Promise<api.RevertTaskResult> => {
const result = await api.revertTask(id, projectId, body);
void refreshTasksRef.current?.();
return result;
}, [projectId]);
const archiveAllDone = useCallback(async (): Promise<Task[]> => {
const archived = await api.archiveAllDone(projectId);
const normalized = archived.map(normalizeTask);
@@ -804,5 +823,5 @@ export function useTasks(options?: UseTasksOptions) {
lastFetchTimeMs.current = Date.now();
}, []);
return { tasks, isStale, lastRefreshErrorAt, createTask, moveTask, pauseTask, unpauseTask, deleteTask, mergeTask, retryTask, resetTask, duplicateTask, updateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, includeArchived, refreshTasks, ingestCreatedTasks, lastFetchTimeMs: lastFetchTimeMs.current };
return { tasks, isStale, lastRefreshErrorAt, createTask, moveTask, pauseTask, unpauseTask, deleteTask, mergeTask, retryTask, resetTask, duplicateTask, updateTask, archiveTask, unarchiveTask, revertTask, archiveAllDone, loadArchivedTasks, includeArchived, refreshTasks, ingestCreatedTasks, lastFetchTimeMs: lastFetchTimeMs.current };
}

View File

@@ -8213,6 +8213,17 @@
"retry": "Retry",
"retryFailed": "Failed to retry {{taskId}}: {{error}}",
"retrying": "Retrying…",
"revert": "Revert",
"revertAiCreated": "Created undo task {{id}}",
"revertAlreadyOpen": "An undo task is already open: {{id}}",
"revertAlreadyReverted": "{{taskId}} was already reverted",
"revertConflictMessage": "Git revert conflicts with later changes. Create an AI task to undo this?",
"revertConflictTitle": "Revert Conflict",
"revertFailed": "Failed to revert {{taskId}}",
"revertNeedsHuman": "Cannot auto-revert {{taskId}}: {{reason}}",
"revertNeedsHumanDefault": "human review required",
"reverted": "Reverted {{taskId}} in commit {{sha}}",
"revertTask": "Revert this task's changes",
"reviewerModel": "Reviewer Model",
"save": "Save",
"saving": "Saving...",