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:
7
.changeset/fn-7525-revert-card-affordance.md
Normal file
7
.changeset/fn-7525-revert-card-affordance.md
Normal 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").
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) : []),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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...",
|
||||
|
||||
Reference in New Issue
Block a user