Merge remote-tracking branch 'origin/main'

# Conflicts:
#	packages/dashboard/app/components/DockTaskList.tsx
This commit is contained in:
Fusion Agent
2026-08-27 02:53:58 +00:00
12 changed files with 216 additions and 82 deletions

View 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.

View File

@@ -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.
@@ -2564,7 +2566,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

View File

@@ -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}

View File

@@ -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}

View File

@@ -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";
@@ -78,20 +78,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");
@@ -116,12 +122,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} onUpdateTask={onUpdateTask} 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>
@@ -137,6 +137,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}
/*
@@ -144,6 +145,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}
onUpdateTask={onUpdateTask}
addToast={addToast}
prAuthAvailable={prAuthAvailable}

View File

@@ -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);

View File

@@ -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> = {
@@ -1209,10 +1209,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;
}
@@ -2263,6 +2272,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),
@@ -2272,7 +2289,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) : []),
@@ -2986,18 +3003,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")}
@@ -3168,6 +3173,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:
@@ -3451,6 +3459,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:

View File

@@ -2196,4 +2196,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); }

View File

@@ -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 () => {

View File

@@ -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);

View File

@@ -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();

View File

@@ -7660,6 +7660,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} />,