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
|
- 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
|
- 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
|
- 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
|
- 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.
|
- 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.
|
- 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`.
|
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)
|
### 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.
|
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:
|
// 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.
|
// 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 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 } : {}),
|
...(currentProject ? { projectId: currentProject.id } : {}),
|
||||||
searchQuery: searchQuery || undefined,
|
searchQuery: searchQuery || undefined,
|
||||||
@@ -1241,7 +1241,7 @@ function AppInner() {
|
|||||||
|
|
||||||
// Props for the extracted <MainContent> switch (see components/dashboard/MainContent.tsx).
|
// 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.
|
// 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:
|
FNXC:OpenTasksInRightSidebar 2026-06-28-00:00:
|
||||||
@@ -1382,6 +1382,7 @@ function AppInner() {
|
|||||||
retryTask,
|
retryTask,
|
||||||
archiveTask,
|
archiveTask,
|
||||||
unarchiveTask,
|
unarchiveTask,
|
||||||
|
revertTask,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
archiveAllDone,
|
archiveAllDone,
|
||||||
loadArchivedTasks,
|
loadArchivedTasks,
|
||||||
@@ -1810,7 +1811,7 @@ function AppInner() {
|
|||||||
}}
|
}}
|
||||||
onPlanningMode={openPlanningWithInitialPlanWithNav}
|
onPlanningMode={openPlanningWithInitialPlanWithNav}
|
||||||
onSubtaskBreakdown={subtaskBreakdownEnabled ? openSubtaskBreakdownWithNav : undefined}
|
onSubtaskBreakdown={subtaskBreakdownEnabled ? openSubtaskBreakdownWithNav : undefined}
|
||||||
taskOperations={{ moveTask, deleteTask, mergeTask, archiveTask, retryTask, resetTask, duplicateTask }}
|
taskOperations={{ moveTask, deleteTask, mergeTask, archiveTask, revertTask, retryTask, resetTask, duplicateTask }}
|
||||||
deepLink={{ handleDetailClose }}
|
deepLink={{ handleDetailClose }}
|
||||||
settings={{ prAuthAvailable, autoMerge, taskDetailChatFirst, themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors, resolvedThemeMode, setThemeMode, setColorTheme, setDashboardFontScalePct, setShadcnCustomColors, setQuickChatButtonModeImmediate }}
|
settings={{ prAuthAvailable, autoMerge, taskDetailChatFirst, themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors, resolvedThemeMode, setThemeMode, setColorTheme, setDashboardFontScalePct, setShadcnCustomColors, setQuickChatButtonModeImmediate }}
|
||||||
onSettingsClose={handleSettingsCloseWithNav}
|
onSettingsClose={handleSettingsCloseWithNav}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
fetchAgentTasks,
|
fetchAgentTasks,
|
||||||
archiveTask,
|
archiveTask,
|
||||||
unarchiveTask,
|
unarchiveTask,
|
||||||
|
revertTask,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
ApiRequestError,
|
ApiRequestError,
|
||||||
moveTask,
|
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", () => {
|
describe("deleteTask", () => {
|
||||||
it("sends DELETE to task endpoint", async () => {
|
it("sends DELETE to task endpoint", async () => {
|
||||||
const deletedTask: Task = { ...FAKE_DETAIL, column: "done" };
|
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" });
|
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[]> {
|
export function archiveAllDone(projectId?: string): Promise<Task[]> {
|
||||||
return api<{ archived: Task[] }>(withProjectId("/tasks/archive-all-done", projectId), { method: "POST" }).then(
|
return api<{ archived: Task[] }>(withProjectId("/tasks/archive-all-done", projectId), { method: "POST" }).then(
|
||||||
(response) => response.archived
|
(response) => response.archived
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState, lazy, Suspense } from "react";
|
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 { ColorTheme, Column, MergeResult, Task, TaskCreateInput, ThemeMode, GithubIssueAction } from "@fusion/core";
|
||||||
import type { UseProjectActionsResult } from "../hooks/useProjectActions";
|
import type { UseProjectActionsResult } from "../hooks/useProjectActions";
|
||||||
import type { ModalManager } from "../hooks/useModalManager";
|
import type { ModalManager } from "../hooks/useModalManager";
|
||||||
@@ -62,6 +62,8 @@ interface AppModalsProps {
|
|||||||
}) => Promise<Task>;
|
}) => Promise<Task>;
|
||||||
mergeTask: (taskId: string) => Promise<MergeResult>;
|
mergeTask: (taskId: string) => Promise<MergeResult>;
|
||||||
archiveTask: (taskId: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
|
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>;
|
retryTask: (taskId: string) => Promise<Task>;
|
||||||
resetTask: (taskId: string) => Promise<Task>;
|
resetTask: (taskId: string) => Promise<Task>;
|
||||||
duplicateTask: (taskId: string) => Promise<Task>;
|
duplicateTask: (taskId: string) => Promise<Task>;
|
||||||
@@ -320,6 +322,7 @@ export function AppModals({
|
|||||||
onDeleteTask={taskOperations.deleteTask}
|
onDeleteTask={taskOperations.deleteTask}
|
||||||
onMergeTask={taskOperations.mergeTask}
|
onMergeTask={taskOperations.mergeTask}
|
||||||
onArchiveTask={taskOperations.archiveTask}
|
onArchiveTask={taskOperations.archiveTask}
|
||||||
|
onRevertTask={taskOperations.revertTask}
|
||||||
onRetryTask={taskOperations.retryTask}
|
onRetryTask={taskOperations.retryTask}
|
||||||
onResetTask={taskOperations.resetTask}
|
onResetTask={taskOperations.resetTask}
|
||||||
onDuplicateTask={taskOperations.duplicateTask}
|
onDuplicateTask={taskOperations.duplicateTask}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import "./Board.css";
|
|||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||||
import { createPortal } from "react-dom";
|
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 { useBlockerFanout } from "../hooks/useBlockerFanout";
|
||||||
import { MOBILE_MEDIA_QUERY, useViewportMode } from "../hooks/useViewportMode";
|
import { MOBILE_MEDIA_QUERY, useViewportMode } from "../hooks/useViewportMode";
|
||||||
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||||||
@@ -55,6 +55,8 @@ interface BoardProps {
|
|||||||
onRetryTask?: (id: string) => Promise<Task>;
|
onRetryTask?: (id: string) => Promise<Task>;
|
||||||
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
|
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
|
||||||
onUnarchiveTask?: (id: string) => 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?: {
|
onDeleteTask?: (id: string, options?: {
|
||||||
removeDependencyReferences?: boolean;
|
removeDependencyReferences?: boolean;
|
||||||
removeLineageReferences?: 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);
|
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||||
/*
|
/*
|
||||||
FNXC:DoneColumnSorting 2026-06-29-16:57:
|
FNXC:DoneColumnSorting 2026-06-29-16:57:
|
||||||
@@ -838,6 +840,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
onRetryTask={onRetryTask}
|
onRetryTask={onRetryTask}
|
||||||
onArchiveTask={onArchiveTask}
|
onArchiveTask={onArchiveTask}
|
||||||
onUnarchiveTask={onUnarchiveTask}
|
onUnarchiveTask={onUnarchiveTask}
|
||||||
|
onRevertTask={onRevertTask}
|
||||||
onDeleteTask={onDeleteTask}
|
onDeleteTask={onDeleteTask}
|
||||||
allTasks={tasks}
|
allTasks={tasks}
|
||||||
availableModels={availableModels}
|
availableModels={availableModels}
|
||||||
@@ -920,6 +923,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
onRetryTask={onRetryTask}
|
onRetryTask={onRetryTask}
|
||||||
onArchiveTask={onArchiveTask}
|
onArchiveTask={onArchiveTask}
|
||||||
onUnarchiveTask={onUnarchiveTask}
|
onUnarchiveTask={onUnarchiveTask}
|
||||||
|
onRevertTask={onRevertTask}
|
||||||
onDeleteTask={onDeleteTask}
|
onDeleteTask={onDeleteTask}
|
||||||
availableModels={availableModels}
|
availableModels={availableModels}
|
||||||
onOpenDetailWithTab={onOpenDetailWithTab}
|
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||||
@@ -976,6 +980,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
onRetryTask={onRetryTask}
|
onRetryTask={onRetryTask}
|
||||||
onArchiveTask={onArchiveTask}
|
onArchiveTask={onArchiveTask}
|
||||||
onUnarchiveTask={onUnarchiveTask}
|
onUnarchiveTask={onUnarchiveTask}
|
||||||
|
onRevertTask={onRevertTask}
|
||||||
onDeleteTask={onDeleteTask}
|
onDeleteTask={onDeleteTask}
|
||||||
availableModels={availableModels}
|
availableModels={availableModels}
|
||||||
onOpenDetailWithTab={onOpenDetailWithTab}
|
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||||
@@ -1027,6 +1032,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
onRetryTask={onRetryTask}
|
onRetryTask={onRetryTask}
|
||||||
onArchiveTask={onArchiveTask}
|
onArchiveTask={onArchiveTask}
|
||||||
onUnarchiveTask={onUnarchiveTask}
|
onUnarchiveTask={onUnarchiveTask}
|
||||||
|
onRevertTask={onRevertTask}
|
||||||
onDeleteTask={onDeleteTask}
|
onDeleteTask={onDeleteTask}
|
||||||
allTasks={tasks}
|
allTasks={tasks}
|
||||||
availableModels={availableModels}
|
availableModels={availableModels}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { groupByWorktree } from "../utils/worktreeGrouping";
|
|||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu";
|
import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu";
|
||||||
import { ChevronDown, ChevronUp, MoreVertical } from "lucide-react";
|
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 { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
|
||||||
import type { DoneColumnSortMode } from "./taskSorting";
|
import type { DoneColumnSortMode } from "./taskSorting";
|
||||||
|
|
||||||
@@ -120,6 +120,8 @@ interface ColumnProps {
|
|||||||
onRetryTask?: (id: string) => Promise<Task>;
|
onRetryTask?: (id: string) => Promise<Task>;
|
||||||
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
|
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
|
||||||
onUnarchiveTask?: (id: string) => 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?: {
|
onDeleteTask?: (id: string, options?: {
|
||||||
removeDependencyReferences?: boolean;
|
removeDependencyReferences?: boolean;
|
||||||
removeLineageReferences?: boolean;
|
removeLineageReferences?: boolean;
|
||||||
@@ -197,7 +199,7 @@ interface ColumnProps {
|
|||||||
getDraggingTaskId?: () => string | null;
|
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");
|
const { t } = useTranslation("app");
|
||||||
// Anchor the board.rejection.* catalog keys for the i18next extractor (it
|
// Anchor the board.rejection.* catalog keys for the i18next extractor (it
|
||||||
// scopes `t` to the useTranslation binding, so the shared translateRejection
|
// scopes `t` to the useTranslation binding, so the shared translateRejection
|
||||||
@@ -850,6 +852,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
|
|||||||
onMergeTask={onMergeTask}
|
onMergeTask={onMergeTask}
|
||||||
onArchiveTask={onArchiveTask}
|
onArchiveTask={onArchiveTask}
|
||||||
onUnarchiveTask={onUnarchiveTask}
|
onUnarchiveTask={onUnarchiveTask}
|
||||||
|
onRevertTask={onRevertTask}
|
||||||
onDeleteTask={onDeleteTask}
|
onDeleteTask={onDeleteTask}
|
||||||
onOpenDetailWithTab={onOpenDetailWithTab}
|
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||||
@@ -890,6 +893,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
|
|||||||
onMergeTask={onMergeTask}
|
onMergeTask={onMergeTask}
|
||||||
onArchiveTask={onArchiveTask}
|
onArchiveTask={onArchiveTask}
|
||||||
onUnarchiveTask={onUnarchiveTask}
|
onUnarchiveTask={onUnarchiveTask}
|
||||||
|
onRevertTask={onRevertTask}
|
||||||
onDeleteTask={onDeleteTask}
|
onDeleteTask={onDeleteTask}
|
||||||
onOpenDetailWithTab={onOpenDetailWithTab}
|
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
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 type { Task, TaskDetail, Column as ColumnType, ColumnId, TaskCreateInput, GithubIssueAction } from "@fusion/core";
|
||||||
import { Column } from "./Column";
|
import { Column } from "./Column";
|
||||||
import { sortTasksForDisplayColumn } from "./taskSorting";
|
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 { ToastType } from "../hooks/useToast";
|
||||||
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
|
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
|
||||||
|
|
||||||
@@ -50,6 +50,8 @@ export interface LaneProps {
|
|||||||
onRetryTask?: (id: string) => Promise<Task>;
|
onRetryTask?: (id: string) => Promise<Task>;
|
||||||
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
|
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
|
||||||
onUnarchiveTask?: (id: string) => 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?: {
|
onDeleteTask?: (id: string, options?: {
|
||||||
removeDependencyReferences?: boolean;
|
removeDependencyReferences?: boolean;
|
||||||
removeLineageReferences?: boolean;
|
removeLineageReferences?: boolean;
|
||||||
@@ -199,6 +201,7 @@ function LaneComponent(props: LaneProps) {
|
|||||||
onRetryTask={props.onRetryTask}
|
onRetryTask={props.onRetryTask}
|
||||||
onArchiveTask={props.onArchiveTask}
|
onArchiveTask={props.onArchiveTask}
|
||||||
onUnarchiveTask={props.onUnarchiveTask}
|
onUnarchiveTask={props.onUnarchiveTask}
|
||||||
|
onRevertTask={props.onRevertTask}
|
||||||
onDeleteTask={props.onDeleteTask}
|
onDeleteTask={props.onDeleteTask}
|
||||||
availableModels={props.availableModels}
|
availableModels={props.availableModels}
|
||||||
onOpenDetailWithTab={props.onOpenDetailWithTab}
|
onOpenDetailWithTab={props.onOpenDetailWithTab}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { sortTasksForDisplayColumn } from "./taskSorting";
|
|||||||
import { batchUpdateTaskModels, fetchNodes, fetchTaskDetail, rebuildTaskSpec, refreshPrStatus, updateTask } from "../api";
|
import { batchUpdateTaskModels, fetchNodes, fetchTaskDetail, rebuildTaskSpec, refreshPrStatus, updateTask } from "../api";
|
||||||
import { TaskDetailContent } from "./TaskDetailModal";
|
import { TaskDetailContent } from "./TaskDetailModal";
|
||||||
import { PrCreateModal } from "./PrCreateModal";
|
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 { QuickEntryBox } from "./QuickEntryBox";
|
||||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
import { NodeHealthDot } from "./NodeHealthDot";
|
import { NodeHealthDot } from "./NodeHealthDot";
|
||||||
@@ -221,6 +221,8 @@ interface ListViewProps {
|
|||||||
onPauseTask?: (id: string) => Promise<Task>;
|
onPauseTask?: (id: string) => Promise<Task>;
|
||||||
onUnpauseTask?: (id: string) => Promise<Task>;
|
onUnpauseTask?: (id: string) => Promise<Task>;
|
||||||
onArchiveTask?: (id: string, options?: { removeLineageReferences?: 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>;
|
onMergeTask: (id: string) => Promise<MergeResult>;
|
||||||
onResetTask?: (id: string) => Promise<Task>;
|
onResetTask?: (id: string) => Promise<Task>;
|
||||||
onDuplicateTask?: (id: string) => Promise<Task>;
|
onDuplicateTask?: (id: string) => Promise<Task>;
|
||||||
@@ -313,6 +315,7 @@ export function ListView({
|
|||||||
onPauseTask,
|
onPauseTask,
|
||||||
onUnpauseTask,
|
onUnpauseTask,
|
||||||
onArchiveTask,
|
onArchiveTask,
|
||||||
|
onRevertTask,
|
||||||
onMergeTask,
|
onMergeTask,
|
||||||
onResetTask,
|
onResetTask,
|
||||||
onDuplicateTask,
|
onDuplicateTask,
|
||||||
@@ -1532,6 +1535,64 @@ export function ListView({
|
|||||||
}
|
}
|
||||||
}, [addToast, confirm, onArchiveTask, t]);
|
}, [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) => {
|
const handleListContextMove = useCallback(async (task: Task, column: ColumnId) => {
|
||||||
try {
|
try {
|
||||||
const hasStepProgress = task.steps.some((step) => step.status !== "pending");
|
const hasStepProgress = task.steps.some((step) => step.status !== "pending");
|
||||||
@@ -1704,6 +1765,21 @@ export function ListView({
|
|||||||
if (task.column === "done" && onArchiveTask) {
|
if (task.column === "done" && onArchiveTask) {
|
||||||
actions.push({ id: "archive", label: t("tasks.archive", "Archive"), onSelect: () => void handleListTaskArchive(task) });
|
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) {
|
for (const transition of model.moveTransitions) {
|
||||||
actions.push({
|
actions.push({
|
||||||
id: `move-${transition.column}`,
|
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 });
|
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));
|
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(
|
const contextMenuActions = useMemo(
|
||||||
() => (contextMenuState ? buildListContextMenuActions(contextMenuState.task) : []),
|
() => (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;
|
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-archive-btn,
|
||||||
.card-unarchive-btn {
|
.card-unarchive-btn,
|
||||||
|
.card-revert-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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-archive-btn,
|
||||||
.card:hover .card-unarchive-btn {
|
.card:hover .card-unarchive-btn,
|
||||||
|
.card:hover .card-revert-btn {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-archive-btn:hover,
|
.card-archive-btn:hover,
|
||||||
.card-unarchive-btn:hover {
|
.card-unarchive-btn:hover,
|
||||||
|
.card-revert-btn:hover {
|
||||||
background: var(--card-hover);
|
background: var(--card-hover);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%);
|
border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-archive-btn:focus,
|
.card-archive-btn:focus,
|
||||||
.card-unarchive-btn:focus {
|
.card-unarchive-btn:focus,
|
||||||
|
.card-revert-btn:focus {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
outline: 1px solid var(--todo);
|
outline: 1px solid var(--todo);
|
||||||
outline-offset: 1px;
|
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. */
|
/* 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-archive-btn,
|
||||||
.card-unarchive-btn {
|
.card-unarchive-btn,
|
||||||
|
.card-revert-btn {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
|
|||||||
// resolver — like resolveEffectiveAutoMerge above — must be imported from its source module
|
// resolver — like resolveEffectiveAutoMerge above — must be imported from its source module
|
||||||
// directly rather than the package barrel.
|
// directly rather than the package barrel.
|
||||||
import { resolveEffectivePlannerOversightLevel } from "../../../core/src/workflow-settings-resolver";
|
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 { GitHubBadge } from "./GitHubBadge";
|
||||||
import { GitLabBadge } from "./GitLabBadge";
|
import { GitLabBadge } from "./GitLabBadge";
|
||||||
import { PrCreateModal } from "./PrCreateModal";
|
import { PrCreateModal } from "./PrCreateModal";
|
||||||
@@ -489,6 +489,14 @@ interface TaskCardProps {
|
|||||||
) => Promise<Task>;
|
) => Promise<Task>;
|
||||||
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
|
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
|
||||||
onUnarchiveTask?: (id: string) => 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?: {
|
onDeleteTask?: (id: string, options?: {
|
||||||
removeDependencyReferences?: boolean;
|
removeDependencyReferences?: boolean;
|
||||||
removeLineageReferences?: boolean;
|
removeLineageReferences?: boolean;
|
||||||
@@ -710,6 +718,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
|||||||
previous.onUpdateTask === next.onUpdateTask &&
|
previous.onUpdateTask === next.onUpdateTask &&
|
||||||
previous.onArchiveTask === next.onArchiveTask &&
|
previous.onArchiveTask === next.onArchiveTask &&
|
||||||
previous.onUnarchiveTask === next.onUnarchiveTask &&
|
previous.onUnarchiveTask === next.onUnarchiveTask &&
|
||||||
|
previous.onRevertTask === next.onRevertTask &&
|
||||||
previous.onDeleteTask === next.onDeleteTask &&
|
previous.onDeleteTask === next.onDeleteTask &&
|
||||||
previous.onPauseTask === next.onPauseTask &&
|
previous.onPauseTask === next.onPauseTask &&
|
||||||
previous.onRetryTask === next.onRetryTask &&
|
previous.onRetryTask === next.onRetryTask &&
|
||||||
@@ -851,6 +860,7 @@ function TaskCardComponent({
|
|||||||
onUpdateTask,
|
onUpdateTask,
|
||||||
onArchiveTask,
|
onArchiveTask,
|
||||||
onUnarchiveTask,
|
onUnarchiveTask,
|
||||||
|
onRevertTask,
|
||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
onPauseTask,
|
onPauseTask,
|
||||||
onRetryTask,
|
onRetryTask,
|
||||||
@@ -1861,6 +1871,83 @@ function TaskCardComponent({
|
|||||||
});
|
});
|
||||||
}, [addToast, onUnarchiveTask, task.id]);
|
}, [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>) => {
|
const handleDeleteClick = useCallback(async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!onDeleteTask) return;
|
if (!onDeleteTask) return;
|
||||||
@@ -2244,7 +2331,7 @@ function TaskCardComponent({
|
|||||||
task.prInfo,
|
task.prInfo,
|
||||||
]);
|
]);
|
||||||
const contextMenuActions = useMemo<TaskMenuActionDescriptor[]>(() => {
|
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 [];
|
return [];
|
||||||
}
|
}
|
||||||
const actions = [...taskActionMenuModel.actions];
|
const actions = [...taskActionMenuModel.actions];
|
||||||
@@ -2254,6 +2341,21 @@ function TaskCardComponent({
|
|||||||
if (task.column === "archived" && onUnarchiveTask) {
|
if (task.column === "archived" && onUnarchiveTask) {
|
||||||
actions.push({ id: "unarchive", label: t("tasks.unarchive", "Unarchive"), onSelect: handleTaskActionUnarchive });
|
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) {
|
if (taskActionMenuModel.reviewAction) {
|
||||||
actions.push({ id: taskActionMenuModel.reviewAction.id, label: taskActionMenuModel.reviewAction.label, disabled: taskActionMenuModel.reviewAction.disabled, onSelect: taskActionMenuModel.reviewAction.onSelect });
|
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));
|
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 hasContextMenuActions = contextMenuActions.length > 0;
|
||||||
|
|
||||||
const closeContextMenu = useCallback(() => {
|
const closeContextMenu = useCallback(() => {
|
||||||
@@ -2970,6 +3072,25 @@ function TaskCardComponent({
|
|||||||
{t("tasks.unarchive", "Unarchive")}
|
{t("tasks.unarchive", "Unarchive")}
|
||||||
</button>
|
</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 && (
|
{task.column === "in-progress" && onMoveTask && (
|
||||||
<div className="card-send-back" ref={sendBackRef}>
|
<div className="card-send-back" ref={sendBackRef}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplica
|
|||||||
import { getRevertOfId, findOpenUndoTaskForSource } from "../utils/taskRevert";
|
import { getRevertOfId, findOpenUndoTaskForSource } from "../utils/taskRevert";
|
||||||
import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
|
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 { 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 type { BoardWorkflowsPayload, WorkflowFieldDefinition, CustomFieldRejection } from "../api";
|
||||||
import { WorkflowIcon } from "./WorkflowIcon";
|
import { WorkflowIcon } from "./WorkflowIcon";
|
||||||
import { ApiRequestError } from "../api";
|
import { ApiRequestError } from "../api";
|
||||||
@@ -347,6 +348,8 @@ export interface TaskDetailModalProps {
|
|||||||
allowResurrection?: boolean;
|
allowResurrection?: boolean;
|
||||||
}) => Promise<Task>;
|
}) => Promise<Task>;
|
||||||
onArchiveTask?: (id: string, options?: { removeLineageReferences?: 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>;
|
onMergeTask: (id: string) => Promise<MergeResult>;
|
||||||
onRetryTask?: (id: string) => Promise<Task>;
|
onRetryTask?: (id: string) => Promise<Task>;
|
||||||
onResetTask?: (id: string) => Promise<Task>;
|
onResetTask?: (id: string) => Promise<Task>;
|
||||||
@@ -615,6 +618,7 @@ export function TaskDetailContent({
|
|||||||
onMoveTask,
|
onMoveTask,
|
||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
onArchiveTask,
|
onArchiveTask,
|
||||||
|
onRevertTask,
|
||||||
onMergeTask,
|
onMergeTask,
|
||||||
onRetryTask,
|
onRetryTask,
|
||||||
onResetTask,
|
onResetTask,
|
||||||
@@ -2585,6 +2589,68 @@ export function TaskDetailContent({
|
|||||||
}
|
}
|
||||||
}, [onArchiveTask, confirm, task.id, nearDuplicateOf, addToast, requestClose]);
|
}, [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;
|
const isTaskPaused = task.paused || task.userPaused;
|
||||||
/*
|
/*
|
||||||
* FNXC:PlanApproval 2026-07-04-21:35:
|
* FNXC:PlanApproval 2026-07-04-21:35:
|
||||||
@@ -5682,6 +5748,25 @@ export function TaskDetailContent({
|
|||||||
</button>
|
</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 */}
|
{/* Actions dropdown — less common operations */}
|
||||||
{taskActionMenuModel.shouldShowActionsMenu && (
|
{taskActionMenuModel.shouldShowActionsMenu && (
|
||||||
<div className="detail-actions-dropdown" ref={actionsMenuRef}>
|
<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 { ClipboardList, GitBranch } from "lucide-react";
|
||||||
import { TaskCard } from "./TaskCard";
|
import { TaskCard } from "./TaskCard";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
|
import type { RevertTaskOptions, RevertTaskResult } from "../api";
|
||||||
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
|
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
|
||||||
import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu";
|
import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu";
|
||||||
|
|
||||||
@@ -31,6 +32,8 @@ interface WorktreeGroupProps {
|
|||||||
onMergeTask?: (id: string) => Promise<MergeResult>;
|
onMergeTask?: (id: string) => Promise<MergeResult>;
|
||||||
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
|
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
|
||||||
onUnarchiveTask?: (id: string) => 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?: {
|
onDeleteTask?: (id: string, options?: {
|
||||||
removeDependencyReferences?: boolean;
|
removeDependencyReferences?: boolean;
|
||||||
removeLineageReferences?: boolean;
|
removeLineageReferences?: boolean;
|
||||||
@@ -81,6 +84,7 @@ function WorktreeGroupComponent({
|
|||||||
onMergeTask,
|
onMergeTask,
|
||||||
onArchiveTask,
|
onArchiveTask,
|
||||||
onUnarchiveTask,
|
onUnarchiveTask,
|
||||||
|
onRevertTask,
|
||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
onOpenDetailWithTab,
|
onOpenDetailWithTab,
|
||||||
taskStuckTimeoutMs,
|
taskStuckTimeoutMs,
|
||||||
@@ -135,6 +139,7 @@ function WorktreeGroupComponent({
|
|||||||
onMergeTask={onMergeTask}
|
onMergeTask={onMergeTask}
|
||||||
onArchiveTask={onArchiveTask}
|
onArchiveTask={onArchiveTask}
|
||||||
onUnarchiveTask={onUnarchiveTask}
|
onUnarchiveTask={onUnarchiveTask}
|
||||||
|
onRevertTask={onRevertTask}
|
||||||
onDeleteTask={onDeleteTask}
|
onDeleteTask={onDeleteTask}
|
||||||
onOpenDetailWithTab={onOpenDetailWithTab}
|
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||||
@@ -171,6 +176,7 @@ function WorktreeGroupComponent({
|
|||||||
onMergeTask={onMergeTask}
|
onMergeTask={onMergeTask}
|
||||||
onArchiveTask={onArchiveTask}
|
onArchiveTask={onArchiveTask}
|
||||||
onUnarchiveTask={onUnarchiveTask}
|
onUnarchiveTask={onUnarchiveTask}
|
||||||
|
onRevertTask={onRevertTask}
|
||||||
onDeleteTask={onDeleteTask}
|
onDeleteTask={onDeleteTask}
|
||||||
onOpenDetailWithTab={onOpenDetailWithTab}
|
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||||
|
|||||||
@@ -1132,6 +1132,157 @@ describe("TaskCard", () => {
|
|||||||
expect(screen.getByLabelText("Unarchive task")).toBeDefined();
|
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 () => {
|
it("keeps two-button delete flow for non-done task", async () => {
|
||||||
const onDeleteTask = vi.fn(async () => makeTask());
|
const onDeleteTask = vi.fn(async () => makeTask());
|
||||||
mockConfirm.mockResolvedValueOnce(false);
|
mockConfirm.mockResolvedValueOnce(false);
|
||||||
|
|||||||
@@ -418,6 +418,69 @@ describe("TaskCard mobile", () => {
|
|||||||
expectRuleToContain(mobileSection, ".card-delete-btn", "height: 28px;");
|
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 () => {
|
it("opens task detail on quick tap", async () => {
|
||||||
const task = createTask({ id: "FN-200", column: "todo" });
|
const task = createTask({ id: "FN-200", column: "todo" });
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export function MainContent({
|
|||||||
retryTask,
|
retryTask,
|
||||||
archiveTask,
|
archiveTask,
|
||||||
unarchiveTask,
|
unarchiveTask,
|
||||||
|
revertTask,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
archiveAllDone,
|
archiveAllDone,
|
||||||
loadArchivedTasks,
|
loadArchivedTasks,
|
||||||
@@ -723,6 +724,7 @@ export function MainContent({
|
|||||||
onMergeTask={mergeTask}
|
onMergeTask={mergeTask}
|
||||||
onArchiveTask={archiveTask}
|
onArchiveTask={archiveTask}
|
||||||
onUnarchiveTask={unarchiveTask}
|
onUnarchiveTask={unarchiveTask}
|
||||||
|
onRevertTask={revertTask}
|
||||||
onDeleteTask={deleteTask}
|
onDeleteTask={deleteTask}
|
||||||
onArchiveAllDone={archiveAllDone}
|
onArchiveAllDone={archiveAllDone}
|
||||||
onLoadArchivedTasks={loadArchivedTasks}
|
onLoadArchivedTasks={loadArchivedTasks}
|
||||||
@@ -828,6 +830,7 @@ export function MainContent({
|
|||||||
onMergeTask={mergeTask}
|
onMergeTask={mergeTask}
|
||||||
onArchiveTask={archiveTask}
|
onArchiveTask={archiveTask}
|
||||||
onUnarchiveTask={unarchiveTask}
|
onUnarchiveTask={unarchiveTask}
|
||||||
|
onRevertTask={revertTask}
|
||||||
onDeleteTask={deleteTask}
|
onDeleteTask={deleteTask}
|
||||||
onArchiveAllDone={archiveAllDone}
|
onArchiveAllDone={archiveAllDone}
|
||||||
onLoadArchivedTasks={loadArchivedTasks}
|
onLoadArchivedTasks={loadArchivedTasks}
|
||||||
@@ -865,6 +868,7 @@ export function MainContent({
|
|||||||
onPauseTask={pauseTask}
|
onPauseTask={pauseTask}
|
||||||
onUnpauseTask={unpauseTask}
|
onUnpauseTask={unpauseTask}
|
||||||
onArchiveTask={archiveTask}
|
onArchiveTask={archiveTask}
|
||||||
|
onRevertTask={revertTask}
|
||||||
onMergeTask={mergeTask}
|
onMergeTask={mergeTask}
|
||||||
onResetTask={resetTask}
|
onResetTask={resetTask}
|
||||||
onDuplicateTask={duplicateTask}
|
onDuplicateTask={duplicateTask}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import type {
|
|||||||
NodeInfo,
|
NodeInfo,
|
||||||
ProjectInfo,
|
ProjectInfo,
|
||||||
ProjectInfoWithSource,
|
ProjectInfoWithSource,
|
||||||
|
RevertTaskOptions,
|
||||||
|
RevertTaskResult,
|
||||||
} from "../../api";
|
} from "../../api";
|
||||||
import type { FusionShellApi } from "../../types/native-shell";
|
import type { FusionShellApi } from "../../types/native-shell";
|
||||||
import type { DetailTaskOpenOptions, DetailTaskTab, ModalManager } from "../../hooks/useModalManager";
|
import type { DetailTaskOpenOptions, DetailTaskTab, ModalManager } from "../../hooks/useModalManager";
|
||||||
@@ -177,6 +179,12 @@ export interface MainContentProps {
|
|||||||
retryTask: (id: string) => Promise<Task>;
|
retryTask: (id: string) => Promise<Task>;
|
||||||
archiveTask: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
|
archiveTask: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
|
||||||
unarchiveTask: (id: string) => 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: (
|
deleteTask: (
|
||||||
id: string,
|
id: string,
|
||||||
options?: {
|
options?: {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplica
|
|||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import type { DetailTaskTab } from "../hooks/useModalManager";
|
import type { DetailTaskTab } from "../hooks/useModalManager";
|
||||||
import { fetchTaskDetail } from "../api";
|
import { fetchTaskDetail } from "../api";
|
||||||
|
import type { RevertTaskOptions, RevertTaskResult } from "../api";
|
||||||
import { getScopedItem } from "../utils/projectStorage";
|
import { getScopedItem } from "../utils/projectStorage";
|
||||||
import { DOCK_FILES_CURRENT_KEY } from "./DockFilesView";
|
import { DOCK_FILES_CURRENT_KEY } from "./DockFilesView";
|
||||||
import { TaskCard } from "./TaskCard";
|
import { TaskCard } from "./TaskCard";
|
||||||
@@ -29,6 +30,8 @@ export interface RightDockControllerInput {
|
|||||||
onMoveTask: (id: string, column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
|
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>;
|
onDeleteTask: (id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; githubIssueAction?: GithubIssueAction; allowResurrection?: boolean }) => Promise<Task>;
|
||||||
onArchiveTask?: (id: string, options?: { removeLineageReferences?: 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>;
|
onMergeTask: (id: string) => Promise<MergeResult>;
|
||||||
onRetryTask?: (id: string) => Promise<Task>;
|
onRetryTask?: (id: string) => Promise<Task>;
|
||||||
onResetTask?: (id: string) => Promise<Task>;
|
onResetTask?: (id: string) => Promise<Task>;
|
||||||
@@ -233,6 +236,7 @@ export function useRightDockController(input: RightDockControllerInput): RightDo
|
|||||||
onMoveTask={input.onMoveTask}
|
onMoveTask={input.onMoveTask}
|
||||||
onDeleteTask={input.onDeleteTask}
|
onDeleteTask={input.onDeleteTask}
|
||||||
onArchiveTask={input.onArchiveTask}
|
onArchiveTask={input.onArchiveTask}
|
||||||
|
onRevertTask={input.onRevertTask}
|
||||||
onMergeTask={input.onMergeTask}
|
onMergeTask={input.onMergeTask}
|
||||||
onRetryTask={input.onRetryTask}
|
onRetryTask={input.onRetryTask}
|
||||||
onResetTask={input.onResetTask}
|
onResetTask={input.onResetTask}
|
||||||
|
|||||||
@@ -751,6 +751,25 @@ export function useTasks(options?: UseTasksOptions) {
|
|||||||
return task;
|
return task;
|
||||||
}, [projectId]);
|
}, [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 archiveAllDone = useCallback(async (): Promise<Task[]> => {
|
||||||
const archived = await api.archiveAllDone(projectId);
|
const archived = await api.archiveAllDone(projectId);
|
||||||
const normalized = archived.map(normalizeTask);
|
const normalized = archived.map(normalizeTask);
|
||||||
@@ -804,5 +823,5 @@ export function useTasks(options?: UseTasksOptions) {
|
|||||||
lastFetchTimeMs.current = Date.now();
|
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",
|
"retry": "Retry",
|
||||||
"retryFailed": "Failed to retry {{taskId}}: {{error}}",
|
"retryFailed": "Failed to retry {{taskId}}: {{error}}",
|
||||||
"retrying": "Retrying…",
|
"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",
|
"reviewerModel": "Reviewer Model",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"saving": "Saving...",
|
"saving": "Saving...",
|
||||||
|
|||||||
Reference in New Issue
Block a user