feat(dashboard): wire optional-steps dropdown into the full New Task modal

This commit is contained in:
gsxdsm
2026-06-21 02:31:18 -07:00
parent f08bf6af2e
commit dcb2190790
4 changed files with 173 additions and 3 deletions

View File

@@ -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<string | null | undefined>(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<string[]>([]);
const [reviewLevel, setReviewLevel] = useState<number | undefined>(undefined);
const [autoMerge, setAutoMerge] = useState<boolean | undefined>(undefined);
const [priority, setPriority] = useState<TaskPriority>(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}

View File

@@ -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<WorkflowDefinition[]>([]);
const [workflowsLoading, setWorkflowsLoading] = useState(false);
const [optionalSteps, setOptionalSteps] = useState<ResolvedWorkflowOptionalStep[]>([]);
const [optionalStepsLoading, setOptionalStepsLoading] = useState(false);
const [autoSaveStatus, setAutoSaveStatus] = useState<"idle" | "saving" | "saved">("idle");
const [baseBranchOptions, setBaseBranchOptions] = useState<string[]>([]);
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({
<small className="workflow-select-help" data-testid="task-workflow-help">
{t("taskForm.workflowHelp", "The selected workflow's steps run automatically around this task's execution.")}
</small>
{optionalStepsLoading ? (
<small className="workflow-optional-steps-loading" data-testid="task-optional-steps-loading">
{t("taskForm.optionalStepsLoading", "Loading optional steps…")}
</small>
) : (
optionalSteps.length > 0 && (
<div className="task-form-optional-steps" data-testid="task-form-optional-steps">
<WorkflowOptionalStepsDropdown
steps={optionalSteps}
enabledIds={enabledOptionalStepIds}
onToggle={toggleOptionalStep}
disabled={disabled}
triggerTestId="task-optional-steps-trigger"
/>
</div>
)
)}
</div>
)}

View File

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

View File

@@ -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."),