feat(dashboard): select a custom workflow when creating a task

New-task modal and the quick-add card (expanded) now expose a workflow picker;
the chosen workflow is applied to the new task via selectTaskWorkflow right
after creation, non-blocking on failure.
This commit is contained in:
gsxdsm
2026-06-03 14:00:59 -07:00
parent 62aba2c095
commit e685dc54e1
3 changed files with 59 additions and 3 deletions

View File

@@ -5,7 +5,7 @@ import { Brain, Link, Lightbulb, ListTree, Zap, ChevronDown, ChevronUp, Bot, Max
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type Task, type TaskPriority, type Settings } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { checkDuplicateTasks, fetchModels, uploadAttachment, fetchSettings, updateGlobalSettings, fetchAgents, DuplicateCandidatesError } from "../api";
import { checkDuplicateTasks, fetchModels, uploadAttachment, fetchSettings, updateGlobalSettings, fetchAgents, selectTaskWorkflow, DuplicateCandidatesError } from "../api";
import type { CreateTaskInput, ModelInfo, Agent, NodeInfo, DuplicateMatch } from "../api";
import { useNodes } from "../hooks/useNodes";
import { ModelSelectionModal } from "./ModelSelectionModal";
@@ -13,6 +13,7 @@ import { NodeHealthDot } from "./NodeHealthDot";
import { DuplicateWarningModal } from "./DuplicateWarningModal";
import { applyPresetToSelection } from "../utils/modelPresets";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
import { WorkflowSelector } from "./WorkflowSelector";
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
const STORAGE_KEY = "kb-inline-create-text";
@@ -114,6 +115,7 @@ export function InlineCreateCard({
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
// isDescriptionExpanded controls fullscreen description editing mode
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
@@ -338,6 +340,19 @@ export function InlineCreateCard({
try {
const task = await onSubmit(input);
// Apply custom workflow if selected (non-blocking — task already exists)
if (selectedWorkflowId) {
try {
await selectTaskWorkflow(task.id, selectedWorkflowId, projectId);
} catch (err) {
if (addToast) {
addToast(getErrorMessage(err) || "Failed to apply workflow", "error");
} else {
console.warn("Failed to apply workflow:", getErrorMessage(err));
}
}
}
// Upload pending images as attachments
if (pendingImages.length > 0) {
const failures: string[] = [];
@@ -376,6 +391,7 @@ export function InlineCreateCard({
setShowAgentPicker(false);
setIsModelModalOpen(false);
setShowPresets(false);
setSelectedWorkflowId(null);
addToast(`Created ${task.id}`, "success");
// Collapse and clear localStorage after successful task creation
@@ -400,6 +416,7 @@ export function InlineCreateCard({
onSubmit,
addToast,
projectId,
selectedWorkflowId,
]);
const handleSubmit = useCallback(async () => {
@@ -1008,6 +1025,15 @@ export function InlineCreateCard({
{browserVerification ? "Browser Verify ✓" : "Browser Verify"}
</button>
<WorkflowSelector
value={selectedWorkflowId}
onChange={(id) => setSelectedWorkflowId(id)}
projectId={projectId}
addToast={addToast}
label="Workflow"
disabled={submitting}
/>
<label className="inline-create-priority-wrap" htmlFor="inline-create-priority-select">
<span className="visually-hidden">Priority</span>
<select

View File

@@ -3,7 +3,7 @@ import { useState, useCallback, useEffect, useRef } from "react";
import { DEFAULT_TASK_PRIORITY, type Task, type TaskCreateInput, type TaskPriority } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { uploadAttachment } from "../api";
import { uploadAttachment, selectTaskWorkflow } from "../api";
import { Bot } from "lucide-react";
import { useSetupReadiness } from "../hooks/useSetupReadiness";
import { SetupWarningBanner } from "./SetupWarningBanner";
@@ -15,6 +15,7 @@ import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useNodes } from "../hooks/useNodes";
import { useViewportMode } from "../hooks/useViewportMode";
import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
import { WorkflowSelector } from "./WorkflowSelector";
interface NewTaskModalProps {
isOpen: boolean;
@@ -53,6 +54,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const [selectedPresetId, setSelectedPresetId] = useState<string>("");
const [presetMode, setPresetMode] = useState<"default" | "preset" | "custom">("default");
const [hasDirtyState, setHasDirtyState] = useState(false);
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
const [selectedWorkflowSteps, setSelectedWorkflowSteps] = useState<string[]>([]);
const [workflowStepsExplicitlySet, setWorkflowStepsExplicitlySet] = useState(false);
const [reviewLevel, setReviewLevel] = useState<number | undefined>(undefined);
@@ -265,6 +267,15 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const task = await onCreateTask(createInput);
// Apply custom workflow if selected (non-blocking — task already exists)
if (selectedWorkflowId) {
try {
await selectTaskWorkflow(task.id, selectedWorkflowId, projectId);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to apply workflow", "error");
}
}
// Upload pending images as attachments
if (pendingImages.length > 0) {
const failures: string[] = [];
@@ -291,6 +302,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setThinkingLevel("");
setSelectedPresetId("");
setPresetMode("default");
setSelectedWorkflowId(null);
setSelectedWorkflowSteps([]);
setWorkflowStepsExplicitlySet(false);
setSelectedAgentId(null);
@@ -310,7 +322,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
} finally {
setIsSubmitting(false);
}
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps, workflowStepsExplicitlySet, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]);
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowId, selectedWorkflowSteps, workflowStepsExplicitlySet, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]);
// Handle keyboard shortcuts
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
@@ -452,6 +464,17 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
)}
</div>
</div>
{/* Custom Workflow */}
<div className="form-group">
<WorkflowSelector
value={selectedWorkflowId}
onChange={(id) => setSelectedWorkflowId(id)}
projectId={projectId}
addToast={addToast}
label="Custom workflow"
disabled={isSubmitting}
/>
</div>
</div>
);

View File

@@ -13,6 +13,12 @@ vi.mock("lucide-react", () => ({
Bot: () => null,
Maximize2: () => null,
Minimize2: () => null,
Workflow: () => null,
}));
// Mock WorkflowSelector to avoid API calls in tests
vi.mock("../WorkflowSelector", () => ({
WorkflowSelector: ({ label }: { label?: string }) => <div data-testid="workflow-selector">{label ?? "Workflow"}</div>,
}));
// Mock the api module
@@ -35,6 +41,7 @@ vi.mock("../../api", () => ({
refineText: vi.fn(),
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
updateGlobalSettings: vi.fn().mockResolvedValue({}),
selectTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: null, enabledWorkflowSteps: [] }),
}));
const mockConfirm = vi.fn();