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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-9213-reverted-label-not-column.md
Normal file
7
.changeset/fn-9213-reverted-label-not-column.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
<!-- FNXC:BoardNavigationDocs 2026-08-21-18:21: FN-115 preserves native task-card activation until horizontal Board pan intent is proven, so stationary clicks continue to reach the configured popup, right-dock, or main-panel detail destination. -->
|
||||
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
|
||||
|
||||
|
||||
@@ -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<string>();
|
||||
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<string, Task[]> = {};
|
||||
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<string>();
|
||||
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 && (
|
||||
<section className="reverted-tasks-section" aria-label={t("tasks.revertedTasks", "Reverted Tasks")} data-testid="board-reverted-tasks">
|
||||
<h2>{t("tasks.revertedTasks", "Reverted Tasks")}</h2>
|
||||
{aggregateRevertedTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={`reverted-${task.id}`}
|
||||
task={task}
|
||||
taskColumnFlags={blockerFanoutColumnFlagsByTaskId.get(task.id)}
|
||||
onOpenDetail={onOpenDetail}
|
||||
onDeleteTask={onDeleteTask}
|
||||
onReviseTask={onReviseTask}
|
||||
addToast={addToast}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
@@ -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 && (
|
||||
<section className="reverted-tasks-section" aria-label={t("tasks.revertedTasks", "Reverted Tasks")} data-testid="board-reverted-tasks">
|
||||
<h2>{t("tasks.revertedTasks", "Reverted Tasks")}</h2>
|
||||
{partitionRevertedTasks(selectedWorkflowTasks).reverted.map((task) => <TaskCard key={`reverted-${task.id}`} task={task} taskColumnFlags={blockerFanoutColumnFlagsByTaskId.get(task.id)} onOpenDetail={onOpenDetail} onDeleteTask={onDeleteTask} onReviseTask={onReviseTask} addToast={addToast} />)}
|
||||
</section>
|
||||
)}
|
||||
{selectedWorkflowArchivedColumn && (
|
||||
<Column
|
||||
key={selectedWorkflowArchivedColumn.id}
|
||||
@@ -1155,6 +1141,7 @@ export function Board({ tasks, projectId, maxConcurrent, effectiveMaxConcurrent
|
||||
onArchiveTask={onArchiveTask}
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
onRevertTask={onRevertTask}
|
||||
onReviseTask={onReviseTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
availableModels={availableModels}
|
||||
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||
|
||||
@@ -149,6 +149,12 @@ interface ColumnProps {
|
||||
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>;
|
||||
/*
|
||||
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<void>;
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
@@ -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<string>();
|
||||
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,
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{revertedTasks.length > 0 && (
|
||||
<section className="dock-task-list__reverted" aria-label={t("tasks.revertedTasks", "Reverted Tasks")} data-testid="dock-reverted-tasks">
|
||||
<h3>{t("tasks.revertedTasks", "Reverted Tasks")}</h3>
|
||||
{revertedTasks.map((task) => <TaskCard key={`reverted-${task.id}`} task={task} taskColumnFlags={columnFlagsByTaskId?.get(task.id)} projectId={projectId} onOpenDetail={handleOpenTask} onDeleteTask={onDeleteTask} onReviseTask={onReviseTask} addToast={addToast} />)}
|
||||
</section>
|
||||
)}
|
||||
{isEmpty ? (
|
||||
<div className="dock-task-list__empty" data-testid="dock-task-list-empty">
|
||||
<p className="dock-task-list__empty-title">{emptyTitle}</p>
|
||||
@@ -131,6 +131,7 @@ export function DockTaskList({ columnFlagsByTaskId,
|
||||
<div key={dockRowKey(task.id, index, list)} className="dock-task-list__row" data-testid={`dock-task-list-row-${task.id}`}>
|
||||
<TaskCard
|
||||
task={task as Task}
|
||||
taskColumnFlags={columnFlagsByTaskId?.get(task.id)}
|
||||
projectId={projectId}
|
||||
onOpenDetail={handleOpenTask}
|
||||
/*
|
||||
@@ -138,6 +139,7 @@ export function DockTaskList({ columnFlagsByTaskId,
|
||||
Every task Delete affordance must reach the shared confirm→delete flow. The right-dock Tasks list is a TaskCard host, so it must pass onDeleteTask instead of rendering cards that silently lack/delete-disable the destructive path.
|
||||
*/
|
||||
onDeleteTask={onDeleteTask}
|
||||
onReviseTask={onReviseTask}
|
||||
addToast={addToast}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
autoMergeEnabled={autoMergeEnabled}
|
||||
|
||||
@@ -764,6 +764,11 @@ rows while preserving their existing badge geometry.
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.list-status-badge--reverted {
|
||||
background: color-mix(in srgb, var(--text-muted) 12%, transparent);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.list-status-badge.failed {
|
||||
background: var(--status-error-bg);
|
||||
color: var(--color-error-dark);
|
||||
|
||||
@@ -39,7 +39,7 @@ import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
|
||||
import { useUnmappedWorkflowRefetch } from "../hooks/useUnmappedWorkflowRefetch";
|
||||
import { TaskContextMenu, buildTaskActionMenuModel, buildTaskMoveMenuItems, getTaskPrAutomationLabel, type TaskContextMenuColumnMetadata, type TaskMenuItemDescriptor } from "./TaskContextMenu";
|
||||
import type { DetailTaskOpenOptions, DetailTaskTab } from "../hooks/useModalManager";
|
||||
import { isTaskReverted, partitionRevertedTasks } from "../utils/taskRevert";
|
||||
import { isTaskReverted } from "../utils/taskRevert";
|
||||
import { getTaskTitleDisplay } from "../utils/taskTitleDisplay";
|
||||
|
||||
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||
@@ -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<string>();
|
||||
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({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{partitionRevertedTasks(tasks).reverted.length > 0 && (
|
||||
<section className="list-reverted-tasks" aria-label={t("tasks.revertedTasks", "Reverted Tasks")} data-testid="list-reverted-tasks">
|
||||
<h2>{t("tasks.revertedTasks", "Reverted Tasks")}</h2>
|
||||
{partitionRevertedTasks(tasks).reverted.map((task) => (
|
||||
<div key={`reverted-${task.id}`} className="list-card">
|
||||
<button type="button" className="btn" onClick={() => onOpenDetail(task)}>{task.id}: {task.title}</button>
|
||||
<button type="button" className="btn" onClick={() => void handleListTaskDelete(task)}>{t("tasks.delete", "Delete")}</button>
|
||||
{onReviseTask && <button type="button" className="btn" onClick={() => onReviseTask(task)}>{t("tasks.revise", "Revise")}</button>}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
{filteredCount === 0 ? (
|
||||
<div className="list-empty">
|
||||
{searchQuery ? t("listView.noTasksMatch", "No tasks match your filter") : t("listView.noTasksYet", "No tasks yet")}
|
||||
@@ -3150,6 +3155,9 @@ export function ListView({
|
||||
{statusBadgeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{isTaskReverted(task.sourceMetadata) && (isCompleteColumnRole(getTaskColumnFlags(task), task.column) || isArchivedColumnRole(getTaskColumnFlags(task), task.column)) && (
|
||||
<span className="list-status-badge list-status-badge--reverted" title={t("tasks.revertedBadgeTitle", "This task's changes were reverted")} aria-label={t("tasks.revertedBadgeTitle", "This task's changes were reverted")}>{t("tasks.revertedBadge", "Reverted")}</span>
|
||||
)}
|
||||
{showOptionalGateBadge && optionalGateBadge && (
|
||||
/*
|
||||
FNXC:TaskCardPlanReviewBadge 2026-07-11-12:10:
|
||||
@@ -3433,6 +3441,9 @@ export function ListView({
|
||||
) : showOptionalGateBadge ? null : (
|
||||
<span className="list-status-badge">-</span>
|
||||
)}
|
||||
{isTaskReverted(task.sourceMetadata) && (isCompleteColumnRole(getTaskColumnFlags(task), task.column) || isArchivedColumnRole(getTaskColumnFlags(task), task.column)) && (
|
||||
<span className="list-status-badge list-status-badge--reverted" title={t("tasks.revertedBadgeTitle", "This task's changes were reverted")} aria-label={t("tasks.revertedBadgeTitle", "This task's changes were reverted")}>{t("tasks.revertedBadge", "Reverted")}</span>
|
||||
)}
|
||||
{showOptionalGateBadge && optionalGateBadge && (
|
||||
/*
|
||||
FNXC:TaskCardPlanReviewBadge 2026-07-11-12:11:
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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<string, { workflowId: string; workflowName: string }>;
|
||||
onOpenDetail?: (task: Task) => void;
|
||||
onMoveTask?: (id: string, column: string) => Promise<Task>;
|
||||
onDeleteTask?: unknown;
|
||||
onReviseTask?: (task: Task) => void;
|
||||
}) => {
|
||||
columnRenderCounts[column] = (columnRenderCounts[column] ?? 0) + 1;
|
||||
return (
|
||||
@@ -124,6 +128,8 @@ vi.mock("../Column", () => ({
|
||||
<article key={task.id} className="card" data-id={task.id} data-testid={`board-task-card-${task.id}`} onClick={() => onOpenDetail?.(task)}>
|
||||
<span data-testid={`board-task-card-title-${task.id}`}>{task.title ?? task.description ?? task.id}</span>
|
||||
<button type="button" data-testid={`board-task-card-control-${task.id}`} onClick={(event) => event.stopPropagation()}>card control</button>
|
||||
{onDeleteTask ? <button type="button">Delete</button> : null}
|
||||
{onReviseTask ? <button type="button" onClick={() => onReviseTask(task)}>Revise</button> : null}
|
||||
<span data-has-move-task={String(Boolean(onMoveTask))} />
|
||||
</article>
|
||||
))}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
<DockTaskList
|
||||
tasks={[reverted]}
|
||||
tasks={[reverted, reverted]}
|
||||
columnFlagsByTaskId={new Map([[reverted.id, { complete: true }]])}
|
||||
onOpenTask={vi.fn()}
|
||||
onDeleteTask={vi.fn()}
|
||||
@@ -195,7 +195,10 @@ describe("DockTaskList", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
<TaskCard task={task} onOpenDetail={noop} onDeleteTask={vi.fn()} onReviseTask={onReviseTask} addToast={noop} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<TaskCard task={makeTask({ column: "done" })} onOpenDetail={noop} addToast={noop} />,
|
||||
|
||||
Reference in New Issue
Block a user