From dcb2190790ae50840e8c924b3ef376d4efc619fd Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 02:31:18 -0700 Subject: [PATCH] feat(dashboard): wire optional-steps dropdown into the full New Task modal --- .../dashboard/app/components/NewTaskModal.tsx | 10 ++- .../dashboard/app/components/TaskForm.tsx | 81 +++++++++++++++++- .../__tests__/NewTaskModal.test.tsx | 84 +++++++++++++++++++ .../components/__tests__/TaskForm.test.tsx | 1 + 4 files changed, 173 insertions(+), 3 deletions(-) diff --git a/packages/dashboard/app/components/NewTaskModal.tsx b/packages/dashboard/app/components/NewTaskModal.tsx index 0c8793e746..fe169d7e7c 100644 --- a/packages/dashboard/app/components/NewTaskModal.tsx +++ b/packages/dashboard/app/components/NewTaskModal.tsx @@ -61,6 +61,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, // `null` = explicit "No workflow", `string` = a specific workflow. Materialized // atomically at create time via the `workflowId` create parameter. const [selectedWorkflowId, setSelectedWorkflowId] = useState(undefined); + // Optional workflow steps the user opted into; TaskForm fetches + seeds these + // from the selected workflow's defaultOn and lifts the enabled set up here. + const [enabledWorkflowSteps, setEnabledWorkflowSteps] = useState([]); const [reviewLevel, setReviewLevel] = useState(undefined); const [autoMerge, setAutoMerge] = useState(undefined); const [priority, setPriority] = useState(DEFAULT_TASK_PRIORITY); @@ -241,6 +244,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, // - null → explicit "No workflow" (store skips default materialization) // - string → that workflow, materialized atomically at create time. ...(selectedWorkflowId !== undefined ? { workflowId: selectedWorkflowId } : {}), + // Optional steps the user toggled on (omit when none so the store keeps its + // default materialization behavior). + ...(enabledWorkflowSteps.length ? { enabledWorkflowSteps } : {}), ...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}), modelPresetId: presetMode === "preset" ? selectedPresetId || undefined : undefined, modelProvider: executorModel && executorSlashIdx !== -1 ? executorModel.slice(0, executorSlashIdx) : undefined, @@ -318,7 +324,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, selectedWorkflowId, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, t]); + }, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowId, enabledWorkflowSteps, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, t]); // Handle keyboard shortcuts const handleKeyDown = useCallback((e: React.KeyboardEvent) => { @@ -506,6 +512,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, onSelectedPresetIdChange={setSelectedPresetId} selectedWorkflowId={selectedWorkflowId} onWorkflowIdChange={setSelectedWorkflowId} + enabledWorkflowSteps={enabledWorkflowSteps} + onEnabledWorkflowStepsChange={setEnabledWorkflowSteps} pendingImages={pendingImages} onImagesChange={setPendingImages} tasks={tasks} diff --git a/packages/dashboard/app/components/TaskForm.tsx b/packages/dashboard/app/components/TaskForm.tsx index 7733f73cf3..70cd5aa181 100644 --- a/packages/dashboard/app/components/TaskForm.tsx +++ b/packages/dashboard/app/components/TaskForm.tsx @@ -1,8 +1,9 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type GlobalSettings, type Task, type TaskPriority, type Settings, type WorkflowDefinition } from "@fusion/core"; +import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type GlobalSettings, type Task, type TaskPriority, type Settings, type WorkflowDefinition, type ResolvedWorkflowOptionalStep } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; -import { fetchModels, fetchSettings, fetchWorkflows, refineText, getRefineErrorMessage, updateGlobalSettings, fetchGlobalSettings, fetchGitBranches, type RefinementType, type ModelInfo, type NodeInfo } from "../api"; +import { fetchModels, fetchSettings, fetchWorkflows, fetchWorkflowOptionalSteps, refineText, getRefineErrorMessage, updateGlobalSettings, fetchGlobalSettings, fetchGitBranches, type RefinementType, type ModelInfo, type NodeInfo } from "../api"; +import { WorkflowOptionalStepsDropdown } from "./WorkflowOptionalStepsDropdown"; import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { NodeHealthDot } from "./NodeHealthDot"; @@ -99,6 +100,11 @@ export interface TaskFormProps { // edit-mode workflow management lives in the task detail Workflow tab. selectedWorkflowId?: string | null; onWorkflowIdChange?: (workflowId: string | null) => void; + // Optional workflow steps the task can opt into. TaskForm fetches + seeds these + // from the selected workflow's `defaultOn` and lifts the enabled set to the + // parent (which puts it in the create payload). Only active in create mode. + enabledWorkflowSteps?: string[]; + onEnabledWorkflowStepsChange?: (ids: string[]) => void; // Attachments pendingImages: PendingImage[]; @@ -176,6 +182,8 @@ export function TaskForm({ onSelectedPresetIdChange, selectedWorkflowId, onWorkflowIdChange, + enabledWorkflowSteps, + onEnabledWorkflowStepsChange, pendingImages, onImagesChange, tasks, @@ -235,6 +243,8 @@ export function TaskForm({ // U6/R3: full workflow definitions for the picker (fragments excluded below). const [workflows, setWorkflows] = useState([]); const [workflowsLoading, setWorkflowsLoading] = useState(false); + const [optionalSteps, setOptionalSteps] = useState([]); + const [optionalStepsLoading, setOptionalStepsLoading] = useState(false); const [autoSaveStatus, setAutoSaveStatus] = useState<"idle" | "saving" | "saved">("idle"); const [baseBranchOptions, setBaseBranchOptions] = useState([]); const [baseBranchCustomMode, setBaseBranchCustomMode] = useState(false); @@ -285,6 +295,56 @@ export function TaskForm({ .catch(() => setGlobalSettings(null)); }, [isActive, projectId, onWorkflowIdChange]); + // Optional workflow steps for the currently-selected workflow (create mode only). + // `null` selection ("No workflow") → no steps; `undefined` → project default. + const effectiveOptionalWorkflowId = + selectedWorkflowId === null + ? null + : (selectedWorkflowId ?? settings?.defaultWorkflowId ?? null); + useEffect(() => { + if (!onWorkflowIdChange) return; // edit mode: optional steps are managed in the Workflow tab. + let cancelled = false; + setOptionalSteps([]); + if (!effectiveOptionalWorkflowId) { + onEnabledWorkflowStepsChange?.([]); + return; + } + setOptionalStepsLoading(true); + fetchWorkflowOptionalSteps(effectiveOptionalWorkflowId, projectId) + .then((steps) => { + if (cancelled) return; + setOptionalSteps(steps); + // Re-seed the enabled set from each step's defaultOn on every workflow change. + onEnabledWorkflowStepsChange?.(steps.filter((s) => s.defaultOn).map((s) => s.templateId)); + }) + .catch(() => { + if (cancelled) return; + setOptionalSteps([]); + onEnabledWorkflowStepsChange?.([]); + }) + .finally(() => { + if (!cancelled) setOptionalStepsLoading(false); + }); + return () => { + cancelled = true; + }; + // onEnabledWorkflowStepsChange intentionally omitted: a new identity each render + // must not re-trigger the fetch/re-seed (would clobber user toggles). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [onWorkflowIdChange, effectiveOptionalWorkflowId, projectId]); + + const enabledOptionalStepIds = enabledWorkflowSteps ?? []; + const toggleOptionalStep = useCallback( + (templateId: string) => { + const current = enabledWorkflowSteps ?? []; + const next = current.includes(templateId) + ? current.filter((id) => id !== templateId) + : [...current, templateId]; + onEnabledWorkflowStepsChange?.(next); + }, + [enabledWorkflowSteps, onEnabledWorkflowStepsChange], + ); + const availablePresets = settings?.modelPresets || []; const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId); const effectiveGithubRepoDefault = resolveEffectiveGithubRepoDefault(settings, globalSettings); @@ -1340,6 +1400,23 @@ export function TaskForm({ {t("taskForm.workflowHelp", "The selected workflow's steps run automatically around this task's execution.")} + {optionalStepsLoading ? ( + + {t("taskForm.optionalStepsLoading", "Loading optional steps…")} + + ) : ( + optionalSteps.length > 0 && ( +
+ +
+ ) + )} )} diff --git a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx index 9622b2cc40..79104ae68f 100644 --- a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx @@ -31,6 +31,7 @@ vi.mock("../../api", () => ({ // U6/R3: TaskForm's picker fetches whole workflows; the per-step // fetchWorkflowSteps + post-create selectTaskWorkflow flow is gone. fetchWorkflows: vi.fn().mockResolvedValue([]), + fetchWorkflowOptionalSteps: vi.fn().mockResolvedValue([]), fetchGlobalSettings: vi.fn().mockResolvedValue({}), fetchGitBranches: vi.fn().mockResolvedValue([]), fetchAgents: vi.fn().mockResolvedValue([]), @@ -239,6 +240,89 @@ describe("NewTaskModal", () => { }); }); + describe("optional workflow steps (U4)", () => { + const WF = { + id: "wf-x", + name: "Custom", + kind: "workflow" as const, + description: "", + ir: { version: "v1" as const, name: "Custom", nodes: [], edges: [] }, + layout: {}, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }; + const STEP = { + templateId: "browser-verification", + name: "Browser Verification", + description: "Verify web application functionality using browser automation", + icon: "globe", + phase: "pre-merge" as const, + defaultOn: false, + }; + + it("includes a toggled-on optional step in the create payload", async () => { + const { fetchWorkflows, fetchWorkflowOptionalSteps } = await import("../../api"); + vi.mocked(fetchWorkflows).mockResolvedValue([WF]); + vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([STEP]); + + const { props } = renderNewTaskModal(); + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { + target: { value: "Verify the login page" }, + }); + fireEvent.change(await screen.findByTestId("task-workflow-select"), { target: { value: "wf-x" } }); + + const trigger = await screen.findByTestId("task-optional-steps-trigger"); + expect(trigger).toHaveTextContent("Steps: none"); + fireEvent.click(trigger); + fireEvent.click(await screen.findByTestId("wf-optional-steps-dropdown-option-browser-verification")); + + fireEvent.click(screen.getByRole("button", { name: "Create Task" })); + await waitFor(() => { + expect(props.onCreateTask).toHaveBeenCalledWith( + expect.objectContaining({ enabledWorkflowSteps: ["browser-verification"] }), + ); + }); + }); + + it("seeds defaultOn steps as pre-enabled and submits them without toggling", async () => { + const { fetchWorkflows, fetchWorkflowOptionalSteps } = await import("../../api"); + vi.mocked(fetchWorkflows).mockResolvedValue([WF]); + vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([{ ...STEP, defaultOn: true }]); + + const { props } = renderNewTaskModal(); + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "task" } }); + fireEvent.change(await screen.findByTestId("task-workflow-select"), { target: { value: "wf-x" } }); + + const trigger = await screen.findByTestId("task-optional-steps-trigger"); + await waitFor(() => expect(trigger).toHaveTextContent("Steps: 1 selected")); + + fireEvent.click(screen.getByRole("button", { name: "Create Task" })); + await waitFor(() => { + expect(props.onCreateTask).toHaveBeenCalledWith( + expect.objectContaining({ enabledWorkflowSteps: ["browser-verification"] }), + ); + }); + }); + + it("renders no dropdown and omits enabledWorkflowSteps for 'No workflow'", async () => { + const { fetchWorkflows, fetchWorkflowOptionalSteps } = await import("../../api"); + vi.mocked(fetchWorkflows).mockResolvedValue([WF]); + vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([STEP]); + + const { props } = renderNewTaskModal(); + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "task" } }); + // "No workflow" → null selection → no optional-steps fetch, no dropdown. + fireEvent.change(await screen.findByTestId("task-workflow-select"), { target: { value: "__none__" } }); + + expect(screen.queryByTestId("task-optional-steps-trigger")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Create Task" })); + await waitFor(() => { + const call = vi.mocked(props.onCreateTask).mock.calls.at(-1)?.[0]; + expect(call).not.toHaveProperty("enabledWorkflowSteps"); + }); + }); + }); + it("submits project-default branch selection by default", async () => { const { props } = renderNewTaskModal(); diff --git a/packages/dashboard/app/components/__tests__/TaskForm.test.tsx b/packages/dashboard/app/components/__tests__/TaskForm.test.tsx index ecf3eba463..bc2ee2c918 100644 --- a/packages/dashboard/app/components/__tests__/TaskForm.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskForm.test.tsx @@ -27,6 +27,7 @@ vi.mock("../../api", () => ({ }), // U6/R3: TaskForm now fetches whole workflows (not steps) for the picker. fetchWorkflows: vi.fn().mockResolvedValue([]), + fetchWorkflowOptionalSteps: vi.fn().mockResolvedValue([]), fetchGlobalSettings: vi.fn().mockResolvedValue({}), refineText: vi.fn().mockResolvedValue("Refined text"), getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),