diff --git a/.changeset/fn-7525-revert-card-affordance.md b/.changeset/fn-7525-revert-card-affordance.md new file mode 100644 index 0000000000..2444d32ac2 --- /dev/null +++ b/.changeset/fn-7525-revert-card-affordance.md @@ -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"). diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 37a2e8cfff..8992295765 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. diff --git a/docs/task-management.md b/docs/task-management.md index 9ce641f8bb..099d216fab 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -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. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 0493e1ac5c..03532054fe 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -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 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} diff --git a/packages/dashboard/app/__tests__/api-git.test.ts b/packages/dashboard/app/__tests__/api-git.test.ts index 154ca109ae..5db19d0e80 100644 --- a/packages/dashboard/app/__tests__/api-git.test.ts +++ b/packages/dashboard/app/__tests__/api-git.test.ts @@ -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" }; diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index b83b9a43cb..92cf9f9955 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -876,6 +876,57 @@ export function unarchiveTask(id: string, projectId?: string): Promise { return api(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 { + return api(withProjectId(`/tasks/${id}/revert`, projectId), { + method: "POST", + body: JSON.stringify(body ?? {}), + }); +} + export function archiveAllDone(projectId?: string): Promise { return api<{ archived: Task[] }>(withProjectId("/tasks/archive-all-done", projectId), { method: "POST" }).then( (response) => response.archived diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index 0d963b6957..028c3869a4 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -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; mergeTask: (taskId: string) => Promise; archiveTask: (taskId: string, options?: { removeLineageReferences?: boolean }) => Promise; + /* 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; retryTask: (taskId: string) => Promise; resetTask: (taskId: string) => Promise; duplicateTask: (taskId: string) => Promise; @@ -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} diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 93171dd429..f343fa8cfb 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -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; onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise; onUnarchiveTask?: (id: string) => Promise; + /* FNXC:TaskRevert 2026-07-05-00:00 (FN-7525): threaded alongside onArchiveTask/onUnarchiveTask. */ + onRevertTask?: (id: string, body?: RevertTaskOptions) => Promise; 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} diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index 9973a13de8..1adfe1ee24 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -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; onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise; onUnarchiveTask?: (id: string) => Promise; + /* FNXC:TaskRevert 2026-07-05-00:00 (FN-7525): threaded alongside onArchiveTask/onUnarchiveTask. */ + onRevertTask?: (id: string, body?: RevertTaskOptions) => Promise; 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} diff --git a/packages/dashboard/app/components/Lane.tsx b/packages/dashboard/app/components/Lane.tsx index 6e78110623..1732934269 100644 --- a/packages/dashboard/app/components/Lane.tsx +++ b/packages/dashboard/app/components/Lane.tsx @@ -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; onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise; onUnarchiveTask?: (id: string) => Promise; + /* FNXC:TaskRevert 2026-07-05-00:00 (FN-7525): threaded alongside onArchiveTask/onUnarchiveTask. */ + onRevertTask?: (id: string, body?: RevertTaskOptions) => Promise; 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} diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index 91ada2b550..f17e08a139 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -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; onUnpauseTask?: (id: string) => Promise; onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise; + /* 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; onMergeTask: (id: string) => Promise; onResetTask?: (id: string) => Promise; onDuplicateTask?: (id: string) => Promise; @@ -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) : []), diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css index 5d3a5983ee..1443982d39 100644 --- a/packages/dashboard/app/components/TaskCard.css +++ b/packages/dashboard/app/components/TaskCard.css @@ -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; } diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 256876340d..2bcb016f3d 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -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; onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise; onUnarchiveTask?: (id: string) => Promise; + /* + 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; 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) => { + 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); + }, [handleRevertClick]); + const handleDeleteClick = useCallback(async (e: React.MouseEvent) => { e.stopPropagation(); if (!onDeleteTask) return; @@ -2244,7 +2331,7 @@ function TaskCardComponent({ task.prInfo, ]); const contextMenuActions = useMemo(() => { - 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")} )} + {/* + 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 && ( + + )} {task.column === "in-progress" && onMoveTask && (
)} + {/* + 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 && ( + + )} + {/* Actions dropdown — less common operations */} {taskActionMenuModel.shouldShowActionsMenu && (
diff --git a/packages/dashboard/app/components/WorktreeGroup.tsx b/packages/dashboard/app/components/WorktreeGroup.tsx index a47183ab6e..449b859248 100644 --- a/packages/dashboard/app/components/WorktreeGroup.tsx +++ b/packages/dashboard/app/components/WorktreeGroup.tsx @@ -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; onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise; onUnarchiveTask?: (id: string) => Promise; + /* FNXC:TaskRevert 2026-07-05-00:00 (FN-7525): threaded alongside onArchiveTask/onUnarchiveTask. */ + onRevertTask?: (id: string, body?: RevertTaskOptions) => Promise; 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} diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index 2c2fbe1707..0f48411f2d 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -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( + ({ 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( + ({ 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( + , + ); + + expect(screen.queryByLabelText("Revert this task's changes")).toBeNull(); + }); + + it("omits the inline Revert button when the task has no landed commit", () => { + render( + ({ 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( + ({ 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( + ({ 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( + , + ); + + 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( + , + ); + + 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); diff --git a/packages/dashboard/app/components/__tests__/board-mobile.test.tsx b/packages/dashboard/app/components/__tests__/board-mobile.test.tsx index 1a22f61910..772ab7f024 100644 --- a/packages/dashboard/app/components/__tests__/board-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/board-mobile.test.tsx @@ -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( + ({ 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( + ({ 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( + ({ 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( + , + ); + expect(containerB.querySelector(".card-revert-btn")).toBeNull(); + }); + it("opens task detail on quick tap", async () => { const task = createTask({ id: "FN-200", column: "todo" }); diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index e25ff47c29..403408d45f 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -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} diff --git a/packages/dashboard/app/components/dashboard/types.ts b/packages/dashboard/app/components/dashboard/types.ts index 0b4ff9da04..9ff8e438a7 100644 --- a/packages/dashboard/app/components/dashboard/types.ts +++ b/packages/dashboard/app/components/dashboard/types.ts @@ -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; archiveTask: (id: string, options?: { removeLineageReferences?: boolean }) => Promise; unarchiveTask: (id: string) => Promise; + /* + 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; deleteTask: ( id: string, options?: { diff --git a/packages/dashboard/app/components/useRightDockController.tsx b/packages/dashboard/app/components/useRightDockController.tsx index 7d84a5d79a..37de8a419c 100644 --- a/packages/dashboard/app/components/useRightDockController.tsx +++ b/packages/dashboard/app/components/useRightDockController.tsx @@ -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; onDeleteTask: (id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; githubIssueAction?: GithubIssueAction; allowResurrection?: boolean }) => Promise; onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise; + /* 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; onMergeTask: (id: string) => Promise; onRetryTask?: (id: string) => Promise; onResetTask?: (id: string) => Promise; @@ -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} diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index 325adde437..1944a8f272 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -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 => { + const result = await api.revertTask(id, projectId, body); + void refreshTasksRef.current?.(); + return result; + }, [projectId]); + const archiveAllDone = useCallback(async (): Promise => { 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 }; } diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index fdecb3d516..10d29e4447 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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...",