From 922e93cf2b0f62d56e76c36fdd3f31d1eb11eeb7 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 26 Aug 2026 19:45:18 -0700 Subject: [PATCH] FN-9213: Display reverted tasks as in-column labels Keep reverted work in its workflow lane while preserving visible resolution controls across dashboard surfaces. - remove separate reverted sections from the board, list, and right dock - label reverted tasks in normal list groups and retain Delete and Revise actions - deduplicate reverted rows during optimistic/refetch overlap and cover desktop and mobile behavior - document the updated workflow and add a patch changeset Files changed: .changeset/fn-9213-reverted-label-not-column.md | 7 +++ docs/dashboard-guide.md | 4 +- packages/dashboard/app/components/Board.tsx | 61 ++++++++------------ packages/dashboard/app/components/Column.tsx | 9 ++- packages/dashboard/app/components/DockTaskList.tsx | 34 +++++------ packages/dashboard/app/components/ListView.css | 5 ++ packages/dashboard/app/components/ListView.tsx | 41 ++++++++----- packages/dashboard/app/components/TaskCard.css | 1 - .../app/components/__tests__/Board.test.tsx | 47 ++++++++++++--- .../app/components/__tests__/DockTaskList.test.tsx | 9 ++- .../app/components/__tests__/ListView.test.tsx | 67 ++++++++++++++++++++++ .../app/components/__tests__/TaskCard.test.tsx | 13 +++++ 12 files changed, 216 insertions(+), 82 deletions(-) Fusion-Task-Id: FN-9213 Fusion-Task-Lineage: 3626da0d-185b-4f4a-b59f-72cfdcd1b60c Co-authored-by: Fusion (runfusion.ai) --- .../fn-9213-reverted-label-not-column.md | 7 ++ docs/dashboard-guide.md | 4 +- packages/dashboard/app/components/Board.tsx | 61 +++++++---------- packages/dashboard/app/components/Column.tsx | 9 ++- .../dashboard/app/components/DockTaskList.tsx | 34 +++++----- .../dashboard/app/components/ListView.css | 5 ++ .../dashboard/app/components/ListView.tsx | 41 +++++++----- .../dashboard/app/components/TaskCard.css | 1 - .../app/components/__tests__/Board.test.tsx | 47 ++++++++++--- .../__tests__/DockTaskList.test.tsx | 9 ++- .../components/__tests__/ListView.test.tsx | 67 +++++++++++++++++++ .../components/__tests__/TaskCard.test.tsx | 13 ++++ 12 files changed, 216 insertions(+), 82 deletions(-) create mode 100644 .changeset/fn-9213-reverted-label-not-column.md diff --git a/.changeset/fn-9213-reverted-label-not-column.md b/.changeset/fn-9213-reverted-label-not-column.md new file mode 100644 index 0000000000..59d704f082 --- /dev/null +++ b/.changeset/fn-9213-reverted-label-not-column.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep reverted tasks labelled in their own workflow column. +category: fix +dev: Removes board, list, and dock reverted sections; threads Column.onReviseTask and adds the List context-menu revise action. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 2b69fdb972..55797458ea 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -182,6 +182,8 @@ On mobile board-card detail, **Back to board** also restores the prior board/car ### Board panning and mobile Kanban column snapping +Reverted work remains in its own workflow column and is marked with a **Reverted** label; it is not collected into a separate board, list, or right-dock group. Its resolution actions remain available on the Board and right-dock card, in the List row context menu, and in Task Detail: use **Delete** to remove the task or **Revise** to create a follow-up revision. + On desktop and tablet, an ordinary task-card click opens the configured task-detail destination (popup, right dock, or main panel). Drag horizontally from an empty Board surface or a task card's noninteractive body or text to pan the Board viewport only after the gesture crosses horizontal intent; that pan suppresses its compatibility click and does not open detail. The gesture never moves a task: card controls, links, fields, editing, context menus, and file drops keep their normal behavior, and **Move to** in the task context menu remains the only relocation path. Fusion does not auto-scroll at Board edges. Phone touch scrolling and column snapping are unchanged. @@ -2561,7 +2563,7 @@ per-modal geometry coverage. ### Reverted task resolution -When a completed task is successfully reverted, Fusion removes it from ordinary Done collections. It remains discoverable in the **Reverted Tasks** resolution section in Board, List, and the right dock. Open the task for provenance, choose **Delete** to use the existing guarded deletion flow, or choose **Revise** to open New Task with the original description prefilled. +When a completed task is successfully reverted, it remains in its ordinary workflow column or list group with a **Reverted** label. It is not moved to a separate group. Open the task for provenance, choose **Delete** from the Board or right-dock card, List row context menu, or task detail to use the existing guarded deletion flow, or choose **Revise** from those same resolution surfaces to open New Task with the original description prefilled. ### Todo Lists plugin enablement diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 6914eaa9c1..f987f39a8c 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -1,6 +1,5 @@ import { sortTasksForDisplayColumn, type TaskColumnSortMode, type Task, type TaskDetail, type Column as ColumnType, type ColumnId, type TaskCreateInput, type GithubIssueAction, type MergeResult } from "@fusion/core"; import { Column } from "./Column"; -import { TaskCard } from "./TaskCard"; import "./Lane.css"; import "./Board.css"; import type { ToastType } from "../hooks/useToast"; @@ -26,7 +25,7 @@ import { writeBoardWorkflowSelection, } from "../utils/boardWorkflowSelection"; import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu"; -import { isTaskReverted, partitionRevertedTasks } from "../utils/taskRevert"; +import { isTaskReverted } from "../utils/taskRevert"; interface BoardProps { tasks: Task[]; @@ -677,8 +676,17 @@ export function Board({ tasks, projectId, maxConcurrent, effectiveMaxConcurrent FNXC:WorkflowBoard 2026-07-05-14:20: Safety net (defense in depth for the taskWorkflowIds refetch above): a card that passed the selected-workflow membership filter genuinely belongs on THIS board, so it must always land in a rendered lane. If its stored `column` is not one this workflow declares (a workflow edited to drop a column, or a create/refetch race that lands an intake-column card before its lane is known), re-home it for DISPLAY into the workflow's intake/first visible column instead of a `??=`-created bucket that is never rendered. Display-only — the task's stored column is untouched. */ + /* + FNXC:TaskRevert 2026-08-27-02:34: + Reverted work stays in its stored lane and may arrive twice during an optimistic/refetch overlap. + Preserve the former reverted-group identity guarantee without deduplicating ordinary task rows. + */ + const seenRevertedTaskIds = new Set(); for (const task of selectedWorkflowTasks) { - if (isTaskReverted(task.sourceMetadata) && selectedWorkflow.columns.find((column) => column.id === task.column)?.flags.complete) continue; + if (isTaskReverted(task.sourceMetadata)) { + if (seenRevertedTaskIds.has(task.id)) continue; + seenRevertedTaskIds.add(task.id); + } const columnId = grouped[task.column] !== undefined ? task.column : (selectedWorkflowCreateColumnId ?? task.column); @@ -820,17 +828,6 @@ export function Board({ tasks, projectId, maxConcurrent, effectiveMaxConcurrent [aggregateArchivedBoardColumns, aggregateVisibleBoardColumns], ); - /* - FNXC:TaskRevert 2026-08-01-20:06: - A successful revert is resolution-required work, not completed work. Aggregate the - shared, deduplicated partition once so All Workflows keeps those cards discoverable - after its complete lanes exclude them, including custom complete columns. - */ - const aggregateRevertedTasks = useMemo( - () => partitionRevertedTasks(tasks).reverted, - [tasks], - ); - const aggregateTasksByColumn = useMemo(() => { const grouped: Record = {}; for (const column of aggregateBoardColumns) grouped[column.id] = []; @@ -844,7 +841,17 @@ export function Board({ tasks, projectId, maxConcurrent, effectiveMaxConcurrent if (column.flags.hiddenFromBoard) hiddenAnywhereColumnIds.add(column.id); } } + /* + FNXC:TaskRevert 2026-08-27-02:34: + Reverted cards no longer have a separate group that deduplicates their ids. Retain that + protection in the aggregate lane grouping so a refetch duplicate cannot render twice. + */ + const seenRevertedTaskIds = new Set(); for (const task of tasks) { + if (isTaskReverted(task.sourceMetadata)) { + if (seenRevertedTaskIds.has(task.id)) continue; + seenRevertedTaskIds.add(task.id); + } const workflowId = getEffectiveTaskWorkflowId(task); const workflowColumn = workflowId ? workflowColumnsByWorkflowId.get(workflowId)?.get(task.column) : null; /* @@ -852,7 +859,6 @@ export function Board({ tasks, projectId, maxConcurrent, effectiveMaxConcurrent Aggregate Board grouping must resolve the task's effective workflow before using a shared column id. If one workflow hides `qa` while another shows it, tasks assigned to the hidden `qa` column stay hidden instead of leaking into the visible aggregate lane. */ if (workflowColumn?.flags.hiddenFromBoard) continue; - if (isTaskReverted(task.sourceMetadata) && workflowColumn?.flags.complete) continue; if (!workflowColumn) { /* FNXC:WorkflowBoard 2026-07-12-23:35: @@ -992,6 +998,7 @@ export function Board({ tasks, projectId, maxConcurrent, effectiveMaxConcurrent onArchiveTask={onArchiveTask} onUnarchiveTask={onUnarchiveTask} onRevertTask={onRevertTask} + onReviseTask={onReviseTask} onDeleteTask={onDeleteTask} allTasks={tasks} availableModels={availableModels} @@ -1020,22 +1027,6 @@ export function Board({ tasks, projectId, maxConcurrent, effectiveMaxConcurrent /> ); })} - {aggregateRevertedTasks.length > 0 && ( -
-

{t("tasks.revertedTasks", "Reverted Tasks")}

- {aggregateRevertedTasks.map((task) => ( - - ))} -
- )} ); @@ -1090,6 +1081,7 @@ export function Board({ tasks, projectId, maxConcurrent, effectiveMaxConcurrent onArchiveTask={onArchiveTask} onUnarchiveTask={onUnarchiveTask} onRevertTask={onRevertTask} + onReviseTask={onReviseTask} onDeleteTask={onDeleteTask} availableModels={availableModels} onOpenDetailWithTab={onOpenDetailWithTab} @@ -1114,12 +1106,6 @@ export function Board({ tasks, projectId, maxConcurrent, effectiveMaxConcurrent /> ); })} - {partitionRevertedTasks(selectedWorkflowTasks).reverted.length > 0 && ( -
-

{t("tasks.revertedTasks", "Reverted Tasks")}

- {partitionRevertedTasks(selectedWorkflowTasks).reverted.map((task) => )} -
- )} {selectedWorkflowArchivedColumn && ( Promise; /* FNXC:TaskRevert 2026-07-05-00:00 (FN-7525): threaded alongside onArchiveTask/onUnarchiveTask. */ onRevertTask?: (id: string, body?: RevertTaskOptions) => Promise; + /* + FNXC:TaskRevert 2026-08-27-02:18: + Reverted work is identified by a card label in its own column rather than a separate column, + so its Delete and Revise resolution actions must reach the in-column card. + */ + onReviseTask?: (task: Task) => void; onDeleteTask?: (id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; @@ -229,7 +235,7 @@ interface ColumnProps { onPromote?: (taskId: string, options?: { force?: boolean }) => Promise; } -function ColumnComponent({ column, tasks, projectId, maxConcurrent, effectiveMaxConcurrent = 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, sortMode, onSortModeChange, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, archivedHasMore, archivedLoadingMore, onLoadMoreArchived, allTasks, availableModels, onPlanningMode, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, holdTaskIds, workflowMode, workflowId, workflowOptions, defaultWorkflowId, columnDisplayName, columnDescription, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote }: ColumnProps) { +function ColumnComponent({ column, tasks, projectId, maxConcurrent, effectiveMaxConcurrent = maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onRevertTask, onReviseTask, onDeleteTask, onArchiveAllDone, sortMode, onSortModeChange, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, archivedHasMore, archivedLoadingMore, onLoadMoreArchived, allTasks, availableModels, onPlanningMode, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, holdTaskIds, workflowMode, workflowId, workflowOptions, defaultWorkflowId, columnDisplayName, columnDescription, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote }: ColumnProps) { const { t } = useTranslation("app"); // Anchor the board.rejection.* catalog keys for the i18next extractor (it // scopes `t` to the useTranslation binding, so the shared translateRejection @@ -1058,6 +1064,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, effectiveMax onArchiveTask={onArchiveTask} onUnarchiveTask={onUnarchiveTask} onRevertTask={onRevertTask} + onReviseTask={onReviseTask} onDeleteTask={onDeleteTask} onOpenDetailWithTab={onOpenDetailWithTab} onOpenMission={onOpenMission} diff --git a/packages/dashboard/app/components/DockTaskList.tsx b/packages/dashboard/app/components/DockTaskList.tsx index 7376da64c2..ebb14cd749 100644 --- a/packages/dashboard/app/components/DockTaskList.tsx +++ b/packages/dashboard/app/components/DockTaskList.tsx @@ -1,7 +1,7 @@ import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { isArchivedColumnRole, isCompleteColumnRole } from "../utils/columnRoles"; -import { partitionRevertedTasks } from "../utils/taskRevert"; +import { isTaskReverted } from "../utils/taskRevert"; import type { GithubIssueAction, Task, TaskDetail } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; import { TaskCard } from "./TaskCard"; @@ -72,20 +72,26 @@ export function DockTaskList({ columnFlagsByTaskId, return { complete: isCompleteColumnRole(flags, task.column), archived: isArchivedColumnRole(flags, task.column) }; }, [columnFlagsByTaskId]); /* - FNXC:TaskRevert 2026-08-01-20:06: - Resolution cards must receive the task's resolved traits, not legacy column names. - A custom complete column otherwise hides the reverted treatment and its Delete and - Revise actions in the dock even though the same task is correctly partitioned here. + FNXC:TaskRevert 2026-08-27-02:34: + Reverted cards now follow normal dock visibility rather than a separate deduplicated section. + Collapse only duplicate reverted ids so refetch overlap cannot render the same resolution card twice. */ - const revertedTasks = useMemo(() => partitionRevertedTasks(tasks as Task[]).reverted, [tasks]); - const doneTasks = useMemo(() => tasks.filter((task) => isTerminal(task).complete && !revertedTasks.some((reverted) => reverted.id === task.id)), [tasks, isTerminal, revertedTasks]); - const visibleTasks = useMemo(() => tasks.filter((task) => { + const displayTasks = useMemo(() => { + const seenRevertedTaskIds = new Set(); + return tasks.filter((task) => { + if (!isTaskReverted(task.sourceMetadata)) return true; + if (seenRevertedTaskIds.has(task.id)) return false; + seenRevertedTaskIds.add(task.id); + return true; + }); + }, [tasks]); + const doneTasks = useMemo(() => displayTasks.filter((task) => isTerminal(task).complete), [displayTasks, isTerminal]); + const visibleTasks = useMemo(() => displayTasks.filter((task) => { const roles = isTerminal(task); if (roles.archived) return false; - if (revertedTasks.some((reverted) => reverted.id === task.id)) return false; if (roles.complete) return showDone; return true; - }), [showDone, tasks, isTerminal, revertedTasks]); + }), [displayTasks, showDone, isTerminal]); const hasDoneTasks = doneTasks.length > 0; const isEmpty = visibleTasks.length === 0; const emptyTitle = tasks.length === 0 ? t("rightDock.noTasksYet", "No tasks yet") : t("rightDock.noActiveTasks", "No active tasks"); @@ -110,12 +116,6 @@ export function DockTaskList({ columnFlagsByTaskId, ) : null} - {revertedTasks.length > 0 && ( -
-

{t("tasks.revertedTasks", "Reverted Tasks")}

- {revertedTasks.map((task) => )} -
- )} {isEmpty ? (

{emptyTitle}

@@ -131,6 +131,7 @@ export function DockTaskList({ columnFlagsByTaskId,
= { @@ -1191,10 +1191,19 @@ export function ListView({ selected-workflow and aggregate groupings. Display-only: the task's stored column is untouched, so the move menu and any engine rebound still see the real column. */ + /* + FNXC:TaskRevert 2026-08-27-02:34: + The removed reverted section previously deduplicated ids. Keep that protection while grouping + rows in their own columns so duplicate optimistic/refetch data cannot duplicate reverted work. + */ + const seenRevertedTaskIds = new Set(); columnFiltered.forEach((task) => { + if (isTaskReverted(task.sourceMetadata)) { + if (seenRevertedTaskIds.has(task.id)) return; + seenRevertedTaskIds.add(task.id); + } const column = workflowMode ? task.column : (isColumn(task.column) ? task.column : DEFAULT_COLUMN); if (groups[column] !== undefined) { - if (isTaskReverted(task.sourceMetadata) && listColumns.find((candidate) => candidate.id === column)?.flags.complete) return; groups[column].push(task); return; } @@ -2245,6 +2254,14 @@ export function ListView({ onSelect: isRevertable ? () => void handleListTaskRevert(task) : undefined, }); } + /* + FNXC:TaskRevert 2026-08-27-02:18: + The removed list reverted section exposed Delete and Revise actions. Delete remains in the + shared menu model; Revise belongs here so desktop right-click and mobile long-press retain it. + */ + if (onReviseTask && isTaskReverted(task.sourceMetadata) && (isCompleteColumnRole(taskColumnFlags, task.column) || isArchivedColumnRole(taskColumnFlags, task.column))) { + actions.push({ id: "revise", label: t("tasks.revise", "Revise"), onSelect: () => onReviseTask(task) }); + } actions.push(...buildTaskMoveMenuItems( model.moveTransitions, (column) => void handleListContextMove(task, column), @@ -2254,7 +2271,7 @@ export function ListView({ actions.push({ id: model.reviewAction.id, label: model.reviewAction.label, disabled: model.reviewAction.disabled, onSelect: model.reviewAction.onSelect }); } return actions.filter((action) => "items" in action || action.tone === "note" || action.disabled === true || Boolean(action.onSelect)); - }, [addToast, autoMerge, columnFlagsById, getTaskColumnFlags, confirm, getListColumnLabel, getTaskPlanningWorkflowId, handleListContextCheckPrStatus, handleListContextEnableGithubTracking, handleListContextMove, handleListTaskArchive, handleListTaskDelete, handleListTaskRevert, isMobile, lastFetchTimeMs, listContextMenuColumns, taskContextMenuColumnsByTaskId, mergeStrategy, onDuplicateTask, onMergeTask, onOpenDetail, onPlanningMode, onPauseTask, onResetTask, onRetryTask, onUnpauseTask, onArchiveTask, onRevertTask, onTasksUpdated, projectId, t, useSinglePaneList]); + }, [addToast, autoMerge, columnFlagsById, getTaskColumnFlags, confirm, getListColumnLabel, getTaskPlanningWorkflowId, handleListContextCheckPrStatus, handleListContextEnableGithubTracking, handleListContextMove, handleListTaskArchive, handleListTaskDelete, handleListTaskRevert, isMobile, lastFetchTimeMs, listContextMenuColumns, taskContextMenuColumnsByTaskId, mergeStrategy, onDuplicateTask, onMergeTask, onOpenDetail, onPlanningMode, onPauseTask, onResetTask, onRetryTask, onUnpauseTask, onArchiveTask, onRevertTask, onReviseTask, onTasksUpdated, projectId, t, useSinglePaneList]); const contextMenuActions = useMemo( () => (contextMenuState ? buildListContextMenuActions(contextMenuState.task) : []), @@ -2968,18 +2985,6 @@ export function ListView({ }} />
- {partitionRevertedTasks(tasks).reverted.length > 0 && ( -
-

{t("tasks.revertedTasks", "Reverted Tasks")}

- {partitionRevertedTasks(tasks).reverted.map((task) => ( -
- - - {onReviseTask && } -
- ))} -
- )} {filteredCount === 0 ? (
{searchQuery ? t("listView.noTasksMatch", "No tasks match your filter") : t("listView.noTasksYet", "No tasks yet")} @@ -3150,6 +3155,9 @@ export function ListView({ {statusBadgeLabel} ) : null} + {isTaskReverted(task.sourceMetadata) && (isCompleteColumnRole(getTaskColumnFlags(task), task.column) || isArchivedColumnRole(getTaskColumnFlags(task), task.column)) && ( + {t("tasks.revertedBadge", "Reverted")} + )} {showOptionalGateBadge && optionalGateBadge && ( /* FNXC:TaskCardPlanReviewBadge 2026-07-11-12:10: @@ -3433,6 +3441,9 @@ export function ListView({ ) : showOptionalGateBadge ? null : ( - )} + {isTaskReverted(task.sourceMetadata) && (isCompleteColumnRole(getTaskColumnFlags(task), task.column) || isArchivedColumnRole(getTaskColumnFlags(task), task.column)) && ( + {t("tasks.revertedBadge", "Reverted")} + )} {showOptionalGateBadge && optionalGateBadge && ( /* FNXC:TaskCardPlanReviewBadge 2026-07-11-12:11: diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css index b7474eb7c3..15c7ecfc63 100644 --- a/packages/dashboard/app/components/TaskCard.css +++ b/packages/dashboard/app/components/TaskCard.css @@ -2174,4 +2174,3 @@ The three-dot menu is the sole card move/action entry point. Keep this shared bu /* FNXC:TaskRevert 2026-08-01-19:51: Reverted tasks require visible resolution controls, not hover-only menu actions. */ .card-reverted-actions { display: inline-flex; gap: var(--space-1); } -.reverted-tasks-section { display: grid; gap: var(--space-2); padding: var(--space-3); } diff --git a/packages/dashboard/app/components/__tests__/Board.test.tsx b/packages/dashboard/app/components/__tests__/Board.test.tsx index a14d204788..07ef4f27b8 100644 --- a/packages/dashboard/app/components/__tests__/Board.test.tsx +++ b/packages/dashboard/app/components/__tests__/Board.test.tsx @@ -78,6 +78,8 @@ vi.mock("../Column", () => ({ taskWorkflowBadges, onOpenDetail, onMoveTask, + onDeleteTask, + onReviseTask, }: { column: string; tasks: Task[]; @@ -105,6 +107,8 @@ vi.mock("../Column", () => ({ taskWorkflowBadges?: ReadonlyMap; onOpenDetail?: (task: Task) => void; onMoveTask?: (id: string, column: string) => Promise; + onDeleteTask?: unknown; + onReviseTask?: (task: Task) => void; }) => { columnRenderCounts[column] = (columnRenderCounts[column] ?? 0) + 1; return ( @@ -124,6 +128,8 @@ vi.mock("../Column", () => ({
onOpenDetail?.(task)}> {task.title ?? task.description ?? task.id} + {onDeleteTask ? : null} + {onReviseTask ? : null}
))} @@ -1435,7 +1441,7 @@ describe("Board", () => { expect(screen.getByTestId("column-in-progress").getAttribute("data-has-plan-auto-approve-toggle")).toBe("no"); }); - it("keeps reverted custom-complete tasks discoverable in All Workflows with resolution actions", async () => { + it("keeps reverted custom-complete tasks in their own All Workflows column with resolution actions", async () => { const projectId = "project-all-reverted-resolution"; const shippedWorkflow = { id: "wf-shipped", @@ -1454,15 +1460,40 @@ describe("Board", () => { enableFlag({ [reverted.id]: shippedWorkflow.id }, [DEFAULT_WORKFLOW, shippedWorkflow]); window.localStorage.setItem(scopedKey(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, projectId), ALL_WORKFLOWS_BOARD_VIEW_ID); - renderBoard({ projectId, tasks: [reverted], onDeleteTask: vi.fn().mockResolvedValue(reverted), onReviseTask: vi.fn() }); + renderBoard({ projectId, tasks: [reverted, reverted], onDeleteTask: vi.fn().mockResolvedValue(reverted), onReviseTask: vi.fn() }); await waitFor(() => expect(screen.getByTestId("column-shipped")).toBeDefined()); - expect(screen.getByTestId("column-shipped")).not.toHaveAttribute("data-tasks", expect.stringContaining(reverted.id)); - const resolution = screen.getByTestId("board-reverted-tasks"); - expect(screen.getByTestId("board-resolution-card-FN-REVERTED")).toHaveAttribute("data-complete", "true"); - expect(resolution).toHaveTextContent("Cancelled custom task"); - expect(within(resolution).getByRole("button", { name: "Delete" })).toBeInTheDocument(); - expect(within(resolution).getByRole("button", { name: "Revise" })).toBeInTheDocument(); + const shippedColumn = screen.getByTestId("column-shipped"); + expect(JSON.parse(shippedColumn.getAttribute("data-tasks") ?? "[]")).toHaveLength(1); + expect(shippedColumn).toHaveAttribute("data-tasks", expect.stringContaining(reverted.id)); + expect(screen.queryByTestId("board-reverted-tasks")).toBeNull(); + expect(within(shippedColumn).getByRole("button", { name: "Delete" })).toBeInTheDocument(); + expect(within(shippedColumn).getByRole("button", { name: "Revise" })).toBeInTheDocument(); + }); + + it("deduplicates reverted work in its selected-workflow column", async () => { + const projectId = "project-selected-reverted-dedup"; + const shippedWorkflow = { + id: "wf-selected-shipped", + name: "Selected Shipped Flow", + columns: [ + { id: "intake", name: "Intake", flags: { intake: true } }, + { id: "shipped", name: "Shipped", flags: { complete: true } }, + ], + }; + const reverted = mkTask({ + id: "FN-SELECTED-REVERTED", + column: "shipped", + sourceMetadata: { revertedAt: "2026-08-01T00:00:00.000Z" }, + }); + enableFlag({ [reverted.id]: shippedWorkflow.id }, [DEFAULT_WORKFLOW, shippedWorkflow]); + window.localStorage.setItem(scopedKey(BOARD_WORKFLOW_SELECTION_STORAGE_KEY, projectId), shippedWorkflow.id); + + renderBoard({ projectId, tasks: [reverted, reverted] }); + + await waitFor(() => expect(screen.getByTestId("column-shipped")).toBeDefined()); + expect(JSON.parse(screen.getByTestId("column-shipped").getAttribute("data-tasks") ?? "[]")).toHaveLength(1); + expect(screen.queryByTestId("board-reverted-tasks")).toBeNull(); }); it("passes auto-merge toggle to selected workflow human-review columns", async () => { diff --git a/packages/dashboard/app/components/__tests__/DockTaskList.test.tsx b/packages/dashboard/app/components/__tests__/DockTaskList.test.tsx index 7bedbfd0b7..c7d9938c94 100644 --- a/packages/dashboard/app/components/__tests__/DockTaskList.test.tsx +++ b/packages/dashboard/app/components/__tests__/DockTaskList.test.tsx @@ -176,7 +176,7 @@ describe("DockTaskList", () => { FNXC:RightDockTasks 2026-06-28-18:42: Empty right-dock task states must distinguish a truly empty list from a list whose only rows are completed or archived, so the compact panel never renders blank and the Show Done affordance remains reachable when completed rows exist. */ - it("routes dock reverted cards through resolved column flags and revise", () => { + it("renders reverted complete work as an ordinary dock row with revise", () => { const reverted = { ...makeTask("FN-REVERTED", "Cancelled task", "shipped"), description: "first line\nsecond line", @@ -186,7 +186,7 @@ describe("DockTaskList", () => { render( { />, ); - expect(screen.getByTestId("dock-reverted-tasks")).toBeInTheDocument(); + expect(screen.queryByTestId("dock-reverted-tasks")).toBeNull(); + expect(screen.queryByTestId("dock-task-list-row-FN-REVERTED")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Show Done" })); + expect(screen.getAllByTestId("dock-task-list-row-FN-REVERTED")).toHaveLength(1); expect(screen.getByTestId("mock-task-card-FN-REVERTED")).toHaveAttribute("data-complete", "true"); fireEvent.click(screen.getByTestId("mock-task-card-revise-FN-REVERTED")); expect(onReviseTask).toHaveBeenCalledWith(reverted); diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index d11ed77baa..0e46571dba 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -1537,6 +1537,73 @@ describe("ListView", () => { vi.useRealTimers(); }); + it("keeps reverted completed rows labelled and revisable from the desktop context menu", () => { + const viewportSpy = mockDesktopViewport(); + showAllColumnsByDefault(); + const reverted = createMockTask({ + id: "FN-REVERTED", + title: "Reverted desktop task", + column: "done", + status: "done", + sourceMetadata: { revertedAt: "2026-08-01T00:00:00.000Z" }, + }); + const onReviseTask = vi.fn(); + + renderListView({ tasks: [reverted], onReviseTask }); + + expect(screen.queryByTestId("list-reverted-tasks")).toBeNull(); + expect(document.querySelector('.list-row[data-id="FN-REVERTED"]')).toHaveTextContent("Reverted"); + fireEvent.contextMenu(document.querySelector('.list-row[data-id="FN-REVERTED"]') as HTMLElement, { clientX: 40, clientY: 50 }); + expect(screen.getByRole("menuitem", { name: "Delete" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("menuitem", { name: "Revise" })); + expect(onReviseTask).toHaveBeenCalledWith(reverted); + viewportSpy.mockRestore(); + }); + + it("deduplicates reverted rows in their ordinary list group", () => { + const viewportSpy = mockDesktopViewport(); + showAllColumnsByDefault(); + const reverted = createMockTask({ + id: "FN-REVERTED-DUPLICATE", + title: "Reverted duplicate task", + column: "done", + status: "done", + sourceMetadata: { revertedAt: "2026-08-01T00:00:00.000Z" }, + }); + + renderListView({ tasks: [reverted, reverted] }); + + expect(screen.queryByTestId("list-reverted-tasks")).toBeNull(); + expect(document.querySelectorAll('.list-row[data-id="FN-REVERTED-DUPLICATE"]')).toHaveLength(1); + viewportSpy.mockRestore(); + }); + + it("keeps reverted completed rows labelled and revisable from mobile long-press", () => { + vi.useFakeTimers(); + const viewportSpy = mockMobileViewport(); + const reverted = createMockTask({ + id: "FN-REVERTED-MOBILE", + title: "Reverted mobile task", + column: "done", + status: "done", + sourceMetadata: { revertedAt: "2026-08-01T00:00:00.000Z" }, + }); + const onReviseTask = vi.fn(); + + renderListView({ tasks: [reverted], onReviseTask }); + + const card = document.querySelector('.list-card[data-id="FN-REVERTED-MOBILE"]') as HTMLElement; + expect(card).toHaveTextContent("Reverted"); + fireEvent.pointerDown(card, { pointerType: "touch", pointerId: 1, clientX: 24, clientY: 32 }); + act(() => { + vi.advanceTimersByTime(550); + }); + fireEvent.pointerUp(screen.getByRole("menuitem", { name: "Revise" }), { pointerType: "touch", pointerId: 2 }); + expect(onReviseTask).toHaveBeenCalledWith(reverted); + viewportSpy.mockRestore(); + vi.useRealTimers(); + }); + it("opens refine from a mobile done-card long-press", () => { vi.useFakeTimers(); const viewportSpy = mockMobileViewport(); diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index f384fc27ef..e45141a11b 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -7648,6 +7648,19 @@ describe("TaskCard reverted chip", () => { expect(screen.getByLabelText("This task's changes were reverted")).toBeInTheDocument(); }); + it("renders Delete and Revise resolution actions when handlers are supplied", () => { + const onReviseTask = vi.fn(); + const task = makeTask({ column: "done", sourceMetadata: { revertedAt: "2026-07-16T00:00:00.000Z" } }); + render( + , + ); + + const actions = document.querySelector(".card-reverted-actions") as HTMLElement; + expect(within(actions).getByRole("button", { name: "Delete" })).toBeInTheDocument(); + fireEvent.click(within(actions).getByRole("button", { name: "Revise" })); + expect(onReviseTask).toHaveBeenCalledWith(task); + }); + it("does not render for missing, blank, or non-completed revert markers", () => { const { rerender } = render( ,