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:
gsxdsm
2026-04-25 09:58:53 -07:00
parent a4b4cdb4d4
commit e2bd97bc04
2 changed files with 135 additions and 2 deletions

View File

@@ -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 && (

View File

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