feat(dashboard): add Respecify All action to the Todo column
Adds a dropdown menu (MoreVertical icon) to the Todo column header with a Respecify All action that bulk-moves every visible todo task back to Triage after a confirm dialog. Toasts surface partial-failure counts when the parallel onMoveTask calls don't all succeed. Click-outside and Escape close the popover. Supporting .column-menu / .column-menu-popover / .column-menu-item styles added in styles.css. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useMemo, useState, useCallback, useEffect } from "react";
|
import { memo, useMemo, useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { useFlashOnIncrease } from "../hooks/useFlashOnIncrease";
|
import { useFlashOnIncrease } from "../hooks/useFlashOnIncrease";
|
||||||
import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput } from "@fusion/core";
|
import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput } from "@fusion/core";
|
||||||
import { COLUMN_LABELS, COLUMN_DESCRIPTIONS, getErrorMessage } from "@fusion/core";
|
import { COLUMN_LABELS, COLUMN_DESCRIPTIONS, getErrorMessage } from "@fusion/core";
|
||||||
@@ -8,7 +8,7 @@ import { QuickEntryBox } from "./QuickEntryBox";
|
|||||||
import { PluginSlot } from "./PluginSlot";
|
import { PluginSlot } from "./PluginSlot";
|
||||||
import { groupByWorktree } from "../utils/worktreeGrouping";
|
import { groupByWorktree } from "../utils/worktreeGrouping";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { ChevronDown, ChevronUp, Archive } from "lucide-react";
|
import { ChevronDown, ChevronUp, Archive, MoreVertical } from "lucide-react";
|
||||||
import type { ModelInfo } from "../api";
|
import type { ModelInfo } from "../api";
|
||||||
|
|
||||||
const PAGINATED_COLUMN_THRESHOLD = 100;
|
const PAGINATED_COLUMN_THRESHOLD = 100;
|
||||||
@@ -68,8 +68,30 @@ interface ColumnProps {
|
|||||||
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup }: ColumnProps) {
|
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup }: ColumnProps) {
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
|
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
const [isRespecifying, setIsRespecifying] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const countFlashing = useFlashOnIncrease(tasks.length);
|
const countFlashing = useFlashOnIncrease(tasks.length);
|
||||||
|
|
||||||
|
// Close the column dropdown menu when the user clicks anywhere else.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMenuOpen) return;
|
||||||
|
function onDocClick(e: MouseEvent) {
|
||||||
|
if (!menuRef.current?.contains(e.target as Node)) {
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") setIsMenuOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", onDocClick);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDocClick);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [isMenuOpen]);
|
||||||
|
|
||||||
// Archived column is collapsed by default - don't show drag state when collapsed
|
// Archived column is collapsed by default - don't show drag state when collapsed
|
||||||
const isArchived = column === "archived";
|
const isArchived = column === "archived";
|
||||||
const isCollapsed = isArchived && collapsed;
|
const isCollapsed = isArchived && collapsed;
|
||||||
@@ -136,6 +158,33 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
|||||||
setVisibleTaskCount((current) => Math.min(current + VISIBLE_TASKS_INCREMENT, tasks.length));
|
setVisibleTaskCount((current) => Math.min(current + VISIBLE_TASKS_INCREMENT, tasks.length));
|
||||||
}, [tasks.length]);
|
}, [tasks.length]);
|
||||||
|
|
||||||
|
const handleRespecifyAll = useCallback(async () => {
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
if (tasks.length === 0) return;
|
||||||
|
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Move all ${tasks.length} todo task${tasks.length === 1 ? "" : "s"} back to triage to be respecified?`,
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
setIsRespecifying(true);
|
||||||
|
try {
|
||||||
|
// Issue moves in parallel — onMoveTask is per-task, no bulk endpoint.
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
tasks.map((task) => onMoveTask(task.id, "triage" as ColumnType)),
|
||||||
|
);
|
||||||
|
const failed = results.filter((r) => r.status === "rejected").length;
|
||||||
|
const moved = results.length - failed;
|
||||||
|
if (failed === 0) {
|
||||||
|
addToast(`Moved ${moved} task${moved === 1 ? "" : "s"} to triage for respec`, "success");
|
||||||
|
} else {
|
||||||
|
addToast(`Moved ${moved} of ${results.length} tasks; ${failed} failed`, "error");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsRespecifying(false);
|
||||||
|
}
|
||||||
|
}, [tasks, onMoveTask, addToast]);
|
||||||
|
|
||||||
const handleArchiveAll = useCallback(async () => {
|
const handleArchiveAll = useCallback(async () => {
|
||||||
if (!onArchiveAllDone) return;
|
if (!onArchiveAllDone) return;
|
||||||
if (tasks.length === 0) return;
|
if (tasks.length === 0) return;
|
||||||
@@ -200,6 +249,38 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
|||||||
{collapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
|
{collapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{column === "todo" && (
|
||||||
|
<div className="column-menu" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-icon btn-sm"
|
||||||
|
onClick={() => setIsMenuOpen((v) => !v)}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={isMenuOpen}
|
||||||
|
aria-label="Todo column actions"
|
||||||
|
title="Column actions"
|
||||||
|
disabled={isRespecifying}
|
||||||
|
>
|
||||||
|
<MoreVertical size={16} />
|
||||||
|
</button>
|
||||||
|
{isMenuOpen && (
|
||||||
|
<div className="column-menu-popover" role="menu">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="column-menu-item"
|
||||||
|
onClick={() => void handleRespecifyAll()}
|
||||||
|
disabled={tasks.length === 0 || isRespecifying}
|
||||||
|
>
|
||||||
|
Respecify All
|
||||||
|
<span className="column-menu-item-hint">
|
||||||
|
Move {tasks.length} task{tasks.length === 1 ? "" : "s"} to Triage
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isCollapsed && <p className="column-desc">{COLUMN_DESCRIPTIONS[column]}</p>}
|
{!isCollapsed && <p className="column-desc">{COLUMN_DESCRIPTIONS[column]}</p>}
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
|
|||||||
@@ -2507,3 +2507,55 @@ input[type="range"]:focus-visible {
|
|||||||
.text-muted { color: var(--text-muted); }
|
.text-muted { color: var(--text-muted); }
|
||||||
.text-secondary { color: var(--text-secondary, var(--text-muted)); }
|
.text-secondary { color: var(--text-secondary, var(--text-muted)); }
|
||||||
.text-dim { color: var(--text-dim, var(--text-muted)); }
|
.text-dim { color: var(--text-dim, var(--text-muted)); }
|
||||||
|
|
||||||
|
/* === Column header dropdown menu (todo column "Respecify All", etc.) ====== */
|
||||||
|
.column-menu {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-menu-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + var(--space-xs));
|
||||||
|
right: 0;
|
||||||
|
min-width: calc(var(--space-2xl) * 6);
|
||||||
|
padding: var(--space-xs);
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 30;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-menu-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: calc(var(--space-xs) / 2);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-menu-item:hover:not(:disabled),
|
||||||
|
.column-menu-item:focus-visible {
|
||||||
|
background: var(--card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-menu-item:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-menu-item-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user