feat(KB-629): complete Step 4 — add bulk edit toolbar with model dropdowns

This commit is contained in:
gsxdsm
2026-03-31 18:14:07 -07:00
parent d32a6223eb
commit e2ed25d54b
2 changed files with 202 additions and 1 deletions

View File

@@ -2,9 +2,10 @@ import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "rea
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3, EyeOff, Eye, ChevronRight } from "lucide-react";
import type { Task, TaskDetail, Column, TaskStep, TaskCreateInput } from "@fusion/core";
import { COLUMN_LABELS, COLUMNS } from "@fusion/core";
import { fetchTaskDetail } from "../api";
import { fetchTaskDetail, batchUpdateTaskModels } from "../api";
import type { ModelInfo } from "../api";
import { QuickEntryBox } from "./QuickEntryBox";
import { CustomModelDropdown } from "./CustomModelDropdown";
import type { ToastType } from "../hooks/useToast";
const COLUMN_COLOR_MAP: Record<Column, string> = {
@@ -42,6 +43,11 @@ interface ListViewProps {
* Called when the user clicks the "Subtask" button in the quick entry box.
*/
onSubtaskBreakdown?: (description: string) => void;
/**
* Called when tasks are updated (e.g., after bulk model update).
* Allows parent to refresh task list or handle optimistically.
*/
onTasksUpdated?: (updatedTasks: Task[]) => void;
}
function getStepProgress(steps: TaskStep[]): string {
@@ -67,6 +73,7 @@ export function ListView({
availableModels,
onPlanningMode,
onSubtaskBreakdown,
onTasksUpdated,
}: ListViewProps) {
const [sortField, setSortField] = useState<SortField>("id");
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
@@ -240,6 +247,96 @@ export function ListView({
return selectedCount > 0 && selectedCount < visibleTaskIds.length;
}, [groupedTasks, selectedTaskIds]);
// Bulk edit state
const [executorModel, setExecutorModel] = useState<string>("__no_change__");
const [validatorModel, setValidatorModel] = useState<string>("__no_change__");
const [isApplying, setIsApplying] = useState(false);
// Handle apply bulk model update
const handleApplyBulkUpdate = useCallback(async () => {
if (selectedTaskIds.size === 0) return;
const taskIds = Array.from(selectedTaskIds).filter((id) => {
const task = tasks.find((t) => t.id === id);
return task && task.column !== "archived";
});
if (taskIds.length === 0) {
addToast("No valid tasks to update (archived tasks cannot be modified)", "error");
return;
}
// Build payload - only include fields that changed from "__no_change__"
const payload: {
taskIds: string[];
modelProvider?: string | null;
modelId?: string | null;
validatorModelProvider?: string | null;
validatorModelId?: string | null;
} = { taskIds };
if (executorModel !== "__no_change__") {
if (executorModel === "") {
// "Use default" - clear override
payload.modelProvider = null;
payload.modelId = null;
} else {
const slashIdx = executorModel.indexOf("/");
if (slashIdx !== -1) {
payload.modelProvider = executorModel.slice(0, slashIdx);
payload.modelId = executorModel.slice(slashIdx + 1);
}
}
}
if (validatorModel !== "__no_change__") {
if (validatorModel === "") {
// "Use default" - clear override
payload.validatorModelProvider = null;
payload.validatorModelId = null;
} else {
const slashIdx = validatorModel.indexOf("/");
if (slashIdx !== -1) {
payload.validatorModelProvider = validatorModel.slice(0, slashIdx);
payload.validatorModelId = validatorModel.slice(slashIdx + 1);
}
}
}
// Check if any changes were made
if (Object.keys(payload).length === 1) {
addToast("No changes to apply", "info");
return;
}
setIsApplying(true);
try {
const result = await batchUpdateTaskModels(
payload.taskIds,
payload.modelProvider,
payload.modelId,
payload.validatorModelProvider,
payload.validatorModelId,
);
// Optimistically update parent with returned tasks
if (onTasksUpdated && result.updated.length > 0) {
onTasksUpdated(result.updated);
}
addToast(`Updated ${result.count} task${result.count === 1 ? "" : "s"}`, "success");
// Reset state
clearSelection();
setExecutorModel("__no_change__");
setValidatorModel("__no_change__");
} catch (err: any) {
addToast(err.message || "Failed to update models", "error");
} finally {
setIsApplying(false);
}
}, [selectedTaskIds, tasks, executorModel, validatorModel, addToast, clearSelection, onTasksUpdated]);
// Toggle a column's visibility
const toggleColumn = useCallback((column: ListColumn) => {
setVisibleColumns((prev) => {
@@ -551,6 +648,37 @@ export function ListView({
</button>
</div>
)}
{/* Bulk Edit Toolbar */}
{selectedTaskIds.size > 0 && availableModels && availableModels.length > 0 && (
<div className="bulk-edit-toolbar">
<span className="bulk-edit-label">Bulk Edit Models:</span>
<div className="bulk-edit-dropdown">
<CustomModelDropdown
models={availableModels}
value={executorModel === "__no_change__" ? "" : executorModel}
onChange={(value) => setExecutorModel(value === "" ? "__no_change__" : value)}
label="Executor Model"
placeholder="No change"
/>
</div>
<div className="bulk-edit-dropdown">
<CustomModelDropdown
models={availableModels}
value={validatorModel === "__no_change__" ? "" : validatorModel}
onChange={(value) => setValidatorModel(value === "" ? "__no_change__" : value)}
label="Validator Model"
placeholder="No change"
/>
</div>
<button
className="btn btn-primary btn-sm bulk-edit-apply-btn"
onClick={handleApplyBulkUpdate}
disabled={isApplying || (executorModel === "__no_change__" && validatorModel === "__no_change__")}
>
{isApplying ? "Applying..." : "Apply"}
</button>
</div>
)}
{onNewTask ? (
<button className="btn btn-primary btn-sm" onClick={onNewTask}>
+ New Task

View File

@@ -3738,6 +3738,79 @@ body {
font-style: italic;
}
/* Selection stats */
.list-selection-stats {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text);
background: var(--todo-bg, rgba(59, 130, 246, 0.1));
border: 1px solid var(--todo-border, rgba(59, 130, 246, 0.3));
border-radius: var(--radius);
padding: 4px 12px;
}
.selection-count {
font-weight: 500;
color: var(--todo);
}
/* Bulk edit toolbar */
.bulk-edit-toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: var(--surface-hover, rgba(0, 0, 0, 0.03));
border: 1px solid var(--border);
border-radius: var(--radius);
margin-left: auto;
}
.bulk-edit-label {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
white-space: nowrap;
}
.bulk-edit-dropdown {
min-width: 180px;
}
.bulk-edit-dropdown .model-combobox-trigger {
font-size: 12px;
padding: 6px 10px;
}
.bulk-edit-apply-btn {
white-space: nowrap;
}
.bulk-edit-apply-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Checkbox column in table */
.list-header-checkbox,
.list-cell-checkbox {
width: 40px;
text-align: center;
padding: 8px 4px;
}
.list-header-checkbox input[type="checkbox"],
.list-cell-checkbox input[type="checkbox"] {
cursor: pointer;
}
.list-cell-checkbox input[type="checkbox"]:disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* Column toggle dropdown */
.list-column-toggle {
position: relative;