feat(core,dashboard): workflow-centric task creation with atomic workflowId materialization

This commit is contained in:
gsxdsm
2026-06-04 23:47:24 -07:00
parent 407ce03bf2
commit ef33d79e78
15 changed files with 615 additions and 945 deletions

View File

@@ -23,6 +23,23 @@ function linearIr(): WorkflowIr {
};
}
/** A single-node fragment IR (start → one node → end). */
function fragmentIr(): WorkflowIr {
return {
version: "v1",
name: "frag",
nodes: [
{ id: "start", kind: "start" },
{ id: "step-1", kind: "prompt", config: { name: "Doc", prompt: "doc it" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "step-1", condition: "success" },
{ from: "step-1", to: "end", condition: "success" },
],
};
}
function branchingIr(): WorkflowIr {
return {
version: "v1",
@@ -172,4 +189,71 @@ describe("TaskStore workflow selection (U3)", () => {
await store.setDefaultWorkflowId(null);
expect(await store.getDefaultWorkflowId()).toBeUndefined();
});
// U6/R3/KTD-4: create-time `workflowId` materializes the selection atomically.
describe("create-time workflowId (U6/R3)", () => {
it("materializes enabledWorkflowSteps atomically when workflowId is given", async () => {
const wf = await store.createWorkflowDefinition({ name: "Pick", ir: linearIr() });
const task = await store.createTask({ description: "with workflow", workflowId: wf.id });
// Reading the task right after create observes the populated steps — no
// intermediate empty state visible to the executor.
const detail = await store.getTask(task.id);
expect(detail.enabledWorkflowSteps).toHaveLength(2);
expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe(wf.id);
expect(store.getTaskWorkflowSelection(task.id)?.stepIds).toEqual(detail.enabledWorkflowSteps);
});
it("explicit workflowId overrides the project default", async () => {
const def = await store.createWorkflowDefinition({ name: "Default", ir: linearIr() });
const chosen = await store.createWorkflowDefinition({ name: "Chosen", ir: linearIr() });
await store.setDefaultWorkflowId(def.id);
const task = await store.createTask({ description: "override default", workflowId: chosen.id });
expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe(chosen.id);
});
it("workflowId: null skips default materialization (explicit No workflow)", async () => {
const def = await store.createWorkflowDefinition({ name: "Default", ir: linearIr() });
await store.setDefaultWorkflowId(def.id);
const task = await store.createTask({ description: "no workflow", workflowId: null });
const detail = await store.getTask(task.id);
expect(detail.enabledWorkflowSteps ?? []).toHaveLength(0);
expect(store.getTaskWorkflowSelection(task.id)).toBeUndefined();
});
it("undefined workflowId still inherits the project default (unchanged)", async () => {
const def = await store.createWorkflowDefinition({ name: "Default", ir: linearIr() });
await store.setDefaultWorkflowId(def.id);
const task = await store.createTask({ description: "inherit" });
const detail = await store.getTask(task.id);
expect(detail.enabledWorkflowSteps).toHaveLength(2);
expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe(def.id);
});
it("rejects a fragment id before creating the task row", async () => {
const frag = await store.createWorkflowDefinition({ name: "Frag", ir: fragmentIr(), kind: "fragment" });
const before = (await store.listTasks({ includeArchived: true })).length;
await expect(
store.createTask({ description: "frag pick", workflowId: frag.id }),
).rejects.toThrow(/fragment/i);
const after = (await store.listTasks({ includeArchived: true })).length;
expect(after).toBe(before);
});
it("rejects an unknown workflow id before creating the task row", async () => {
const before = (await store.listTasks({ includeArchived: true })).length;
await expect(
store.createTask({ description: "bad pick", workflowId: "WF-404" }),
).rejects.toThrow(/not found/i);
const after = (await store.listTasks({ includeArchived: true })).length;
expect(after).toBe(before);
});
});
});

View File

@@ -3975,7 +3975,24 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// When a project default workflow is configured, new tasks inherit it
// (compiled to steps) ahead of the legacy default-on step behavior.
let pendingWorkflowSelection: { workflowId: string; stepIds: string[] } | undefined;
if (input.enabledWorkflowSteps === undefined) {
// U6/R3/KTD-4: an explicit create-time workflowId beats the project default.
// `null` is an explicit opt-out (no workflow), `string` materializes that
// workflow, `undefined` falls through to the default-workflow behavior below.
// Explicit enabledWorkflowSteps still wins over workflowId for trusted callers.
const explicitWorkflowId =
input.enabledWorkflowSteps === undefined ? input.workflowId : undefined;
if (explicitWorkflowId !== undefined) {
if (explicitWorkflowId === null) {
// Explicit "No workflow": skip default materialization entirely.
resolvedWorkflowSteps = undefined;
} else {
// Compile + materialize up front so unknown/fragment ids throw BEFORE
// the task row is created (no orphaned steps, no half-created task).
const selected = await this.materializeExplicitWorkflowSteps(explicitWorkflowId);
resolvedWorkflowSteps = selected.stepIds;
pendingWorkflowSelection = selected;
}
} else if (input.enabledWorkflowSteps === undefined) {
try {
const inherited = await this.materializeDefaultWorkflowSteps();
if (inherited) {
@@ -13448,6 +13465,25 @@ ${stepsSection}`;
return { workflowId, stepIds };
}
/** Resolve an EXPLICITLY requested workflow id (U6/R3/KTD-4) into materialized
* step ids for the create-time `workflowId` parameter. Unlike
* `materializeDefaultWorkflowSteps`, unknown ids and fragments are hard errors
* (thrown BEFORE any task row is created) rather than silent fallbacks, since
* the caller asked for a specific workflow. Compilation happens up front so a
* non-compilable workflow aborts before any rows are written. */
private async materializeExplicitWorkflowSteps(
workflowId: string,
): Promise<{ workflowId: string; stepIds: string[] }> {
const def = await this.getWorkflowDefinition(workflowId);
if (!def) throw new Error(`Workflow '${workflowId}' not found`);
if (def.kind === "fragment") {
throw new Error(`Workflow '${workflowId}' is a fragment and cannot be selected for a task`);
}
const inputs = compileWorkflowToSteps(def.ir);
const stepIds = await this.materializeWorkflowSteps(workflowId, inputs);
return { workflowId, stepIds };
}
/**
* Select a workflow for a task: compile it, materialize its steps, and write
* their ids into the task's enabledWorkflowSteps. Replaces any prior selection

View File

@@ -2291,6 +2291,23 @@ export interface TaskCreateInput {
noCommitsExpected?: boolean;
/** IDs of workflow steps to enable for this task */
enabledWorkflowSteps?: string[];
/**
* Workflow selection applied atomically at task creation (U6/R3/KTD-4).
*
* Semantics:
* - `undefined` → inherit the project default workflow (today's behavior:
* `materializeDefaultWorkflowSteps` runs, falling back to default-on steps).
* - `null` → explicitly NO workflow: skip default materialization entirely;
* the task is created with no custom workflow steps.
* - `string` → that workflow's compiled steps are materialized and selected
* inside the creation flow, overriding any project default. Fragment IDs
* and unknown IDs are rejected with a clear error BEFORE the task row is
* created.
*
* Mutually exclusive with `enabledWorkflowSteps`: when `enabledWorkflowSteps`
* is provided, it takes precedence and `workflowId` materialization is skipped.
*/
workflowId?: string | null;
/** Model preset selected during task creation. Presets resolve to concrete model overrides at creation time. */
modelPresetId?: string;
/** AI model provider override for the executor agent (e.g., "anthropic").

View File

@@ -371,6 +371,7 @@ export async function createTask(
dependencies,
breakIntoSubtasks,
enabledWorkflowSteps,
workflowId,
assignedAgentId,
modelPresetId,
modelProvider,
@@ -407,6 +408,7 @@ export async function createTask(
dependencies,
breakIntoSubtasks,
enabledWorkflowSteps,
workflowId,
assignedAgentId,
modelPresetId,
modelProvider,

View File

@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
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, selectTaskWorkflow } from "../api";
import { uploadAttachment } from "../api";
import { Bot } from "lucide-react";
import { useSetupReadiness } from "../hooks/useSetupReadiness";
import { SetupWarningBanner } from "./SetupWarningBanner";
@@ -16,7 +16,6 @@ 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;
@@ -56,9 +55,10 @@ 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);
// U6/R3: tri-state workflow selection. `undefined` = inherit project default,
// `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);
const [reviewLevel, setReviewLevel] = useState<number | undefined>(undefined);
const [autoMerge, setAutoMerge] = useState<boolean | undefined>(undefined);
const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
@@ -80,18 +80,6 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const { hasAiProvider, hasGithub, loading: setupReadinessLoading } = useSetupReadiness(projectId);
const { nodes } = useNodes();
// Handler for workflow step changes that detects explicit user interaction
const handleWorkflowStepsChange = useCallback((steps: string[]) => {
setWorkflowStepsExplicitlySet(true);
setSelectedWorkflowSteps(steps);
}, []);
// Callback when defaultOn steps are auto-applied by TaskForm
const handleDefaultOnApplied = useCallback(() => {
// defaultOn auto-selection is not "explicit" user interaction
setWorkflowStepsExplicitlySet(false);
}, []);
// Load agents for agent picker
const loadAgents = useCallback(() => {
setShowAgentPicker(true);
@@ -159,12 +147,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
description.trim() !== "" ||
dependencies.length > 0 ||
pendingImages.length > 0 ||
selectedWorkflowId !== null ||
selectedWorkflowId !== undefined ||
executorModel !== "" ||
validatorModel !== "" ||
planningModel !== "" ||
thinkingLevel !== "" ||
selectedWorkflowSteps.length > 0 ||
selectedAgentId !== null ||
reviewLevel !== undefined ||
autoMerge !== undefined ||
@@ -176,7 +163,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
githubTrackingEnabled ||
githubRepoOverrideTrimmed !== "";
setHasDirtyState(isDirty);
}, [description, dependencies, pendingImages, selectedWorkflowId, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]);
}, [description, dependencies, pendingImages, selectedWorkflowId, executorModel, validatorModel, planningModel, thinkingLevel, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]);
const handleClose = useCallback(async () => {
if (hasDirtyState) {
@@ -199,9 +186,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setThinkingLevel("");
setSelectedPresetId("");
setPresetMode("default");
setSelectedWorkflowId(null);
setSelectedWorkflowSteps([]);
setWorkflowStepsExplicitlySet(false);
setSelectedWorkflowId(undefined);
setSelectedAgentId(null);
setShowAgentPicker(false);
setReviewLevel(undefined);
@@ -238,9 +223,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
description: trimmedDesc,
column: "triage",
dependencies: dependencies.length ? dependencies : undefined,
// When user explicitly cleared all workflow steps, send empty array to prevent backend re-applying defaults.
// When user hasn't interacted with workflow steps (or left auto-selected defaults), send undefined to let backend apply defaults.
enabledWorkflowSteps: workflowStepsExplicitlySet ? (selectedWorkflowSteps.length > 0 ? selectedWorkflowSteps : []) : undefined,
// U6/R3: forward the workflow selection only when the user changed it.
// - undefined → omit (store inherits the project default, today's behavior)
// - null → explicit "No workflow" (store skips default materialization)
// - string → that workflow, materialized atomically at create time.
...(selectedWorkflowId !== undefined ? { workflowId: selectedWorkflowId } : {}),
...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}),
modelPresetId: presetMode === "preset" ? selectedPresetId || undefined : undefined,
modelProvider: executorModel && executorSlashIdx !== -1 ? executorModel.slice(0, executorSlashIdx) : undefined,
@@ -269,17 +256,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
: {}),
};
// U6/R3: the workflow is now materialized atomically inside createTask via
// the `workflowId` parameter — no post-create selectTaskWorkflow call, so
// the executor can never observe the task with the wrong step set.
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[] = [];
@@ -306,9 +287,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setThinkingLevel("");
setSelectedPresetId("");
setPresetMode("default");
setSelectedWorkflowId(null);
setSelectedWorkflowSteps([]);
setWorkflowStepsExplicitlySet(false);
setSelectedWorkflowId(undefined);
setSelectedAgentId(null);
setShowAgentPicker(false);
setReviewLevel(undefined);
@@ -326,7 +305,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, selectedWorkflowSteps, workflowStepsExplicitlySet, 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, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, t]);
// Handle keyboard shortcuts
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
@@ -468,17 +447,9 @@ 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>
{/* U6/R3: the workflow picker now lives inside TaskForm (a whole-workflow
dropdown materialized atomically at create time), replacing the prior
standalone WorkflowSelector + post-create selectTaskWorkflow flow. */}
</div>
);
@@ -520,9 +491,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
onPresetModeChange={setPresetMode}
selectedPresetId={selectedPresetId}
onSelectedPresetIdChange={setSelectedPresetId}
selectedWorkflowSteps={selectedWorkflowSteps}
onWorkflowStepsChange={handleWorkflowStepsChange}
onDefaultOnApplied={handleDefaultOnApplied}
selectedWorkflowId={selectedWorkflowId}
onWorkflowIdChange={setSelectedWorkflowId}
pendingImages={pendingImages}
onImagesChange={setPendingImages}
tasks={tasks}

View File

@@ -2468,8 +2468,6 @@ export function TaskDetailContent({
onPresetModeChange={setEditPresetMode}
selectedPresetId={editSelectedPresetId}
onSelectedPresetIdChange={setEditSelectedPresetId}
selectedWorkflowSteps={editSelectedWorkflowSteps}
onWorkflowStepsChange={setEditSelectedWorkflowSteps}
pendingImages={editPendingImages}
onImagesChange={setEditPendingImages}
tasks={tasks.filter((t) => t.id !== task.id)}

View File

@@ -1,12 +1,12 @@
import { useState, useCallback, useEffect, useRef, type ReactNode } from "react";
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 WorkflowStep } from "@fusion/core";
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type GlobalSettings, type Task, type TaskPriority, type Settings, type WorkflowDefinition } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, updateGlobalSettings, fetchGlobalSettings, fetchGitBranches, type RefinementType, type ModelInfo, type NodeInfo } from "../api";
import { fetchModels, fetchSettings, fetchWorkflows, refineText, getRefineErrorMessage, updateGlobalSettings, fetchGlobalSettings, fetchGitBranches, type RefinementType, type ModelInfo, type NodeInfo } from "../api";
import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { NodeHealthDot } from "./NodeHealthDot";
import { Sparkles, ChevronUp, ChevronDown, X, Maximize2, Minimize2 } from "lucide-react";
import { Sparkles, ChevronUp, ChevronDown, Maximize2, Minimize2 } from "lucide-react";
import { REPO_OVERRIDE_RE, resolveEffectiveGithubRepoDefault } from "./githubTracking";
function getNodeStatusLabel(status: NodeInfo["status"], t: (key: string, defaultValue: string) => string): string {
@@ -39,17 +39,6 @@ function sortBranchNames(branches: string[]): string[] {
}
/** Renders a phase badge using shared .phase-badge classes for consistency */
function phaseBadge(phase: "pre-merge" | "post-merge", id: string, prefix: string, t: (key: string, defaultValue: string) => string): ReactNode {
const phaseClass = phase === "post-merge" ? "phase-badge--post-merge" : "phase-badge--pre-merge";
return (
<span
className={`phase-badge ${phaseClass}`}
data-testid={`${prefix}-${id}`}
>
{phase === "post-merge" ? t("taskForm.phasePostMerge", "Post-merge") : t("taskForm.phasePreMerge", "Pre-merge")}
</span>
);
}
export interface PendingImage {
file: File;
@@ -99,11 +88,15 @@ export interface TaskFormProps {
selectedPresetId: string;
onSelectedPresetIdChange: (id: string) => void;
// Workflow steps
selectedWorkflowSteps: string[];
onWorkflowStepsChange: (steps: string[]) => void;
/** Callback fired when defaultOn steps have been preselected (create mode). Parent can use this to distinguish "no selection yet" from "user explicitly cleared". */
onDefaultOnApplied?: (stepIds: string[]) => void;
// Workflow selection (U6/R3). The form picks a whole workflow (not individual
// steps), applied atomically at task creation via the create-time `workflowId`.
// - `undefined` → inherit the project default (preselected + "(default)" badge).
// - `null` → "No workflow" (listed first).
// - `string` → a specific workflow id.
// The dropdown only renders when `onWorkflowIdChange` is provided (create mode);
// edit-mode workflow management lives in the task detail Workflow tab.
selectedWorkflowId?: string | null;
onWorkflowIdChange?: (workflowId: string | null) => void;
// Attachments
pendingImages: PendingImage[];
@@ -179,9 +172,8 @@ export function TaskForm({
onPresetModeChange,
selectedPresetId,
onSelectedPresetIdChange,
selectedWorkflowSteps,
onWorkflowStepsChange,
onDefaultOnApplied,
selectedWorkflowId,
onWorkflowIdChange,
pendingImages,
onImagesChange,
tasks,
@@ -212,7 +204,6 @@ export function TaskForm({
const hasInitialMoreOptions =
(hideDependencies ? false : dependencies.length > 0) ||
pendingImages.length > 0 ||
selectedWorkflowSteps.length > 0 ||
presetMode !== "default" ||
(priority ?? DEFAULT_TASK_PRIORITY) !== DEFAULT_TASK_PRIORITY ||
executorModel !== "" ||
@@ -239,7 +230,9 @@ export function TaskForm({
const [modelsLoading, setModelsLoading] = useState(false);
const [settings, setSettings] = useState<Settings | null>(null);
const [globalSettings, setGlobalSettings] = useState<GlobalSettings | null>(null);
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
// U6/R3: full workflow definitions for the picker (fragments excluded below).
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
const [workflowsLoading, setWorkflowsLoading] = useState(false);
const [autoSaveStatus, setAutoSaveStatus] = useState<"idle" | "saving" | "saved">("idle");
const [baseBranchOptions, setBaseBranchOptions] = useState<string[]>([]);
const [baseBranchCustomMode, setBaseBranchCustomMode] = useState(false);
@@ -276,13 +269,19 @@ export function TaskForm({
fetchSettings(projectId)
.then((nextSettings) => setSettings(nextSettings))
.catch(() => setSettings(null));
fetchWorkflowSteps(projectId)
.then((steps) => setWorkflowSteps(steps.filter((s) => s.enabled)))
.catch(() => setWorkflowSteps([]));
// U6/R3: load selectable workflows for the picker. Fragments are excluded
// (KTD-1) so they never appear as selectable task workflows.
if (onWorkflowIdChange) {
setWorkflowsLoading(true);
fetchWorkflows(projectId)
.then((defs) => setWorkflows(defs.filter((d) => d.kind !== "fragment")))
.catch(() => setWorkflows([]))
.finally(() => setWorkflowsLoading(false));
}
fetchGlobalSettings()
.then((nextGlobalSettings) => setGlobalSettings(nextGlobalSettings))
.catch(() => setGlobalSettings(null));
}, [isActive, projectId]);
}, [isActive, projectId, onWorkflowIdChange]);
const availablePresets = settings?.modelPresets || [];
const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId);
@@ -292,7 +291,6 @@ export function TaskForm({
const hasMoreOptionSelections =
(hideDependencies ? false : dependencies.length > 0) ||
pendingImages.length > 0 ||
selectedWorkflowSteps.length > 0 ||
presetMode !== "default" ||
(priority ?? DEFAULT_TASK_PRIORITY) !== DEFAULT_TASK_PRIORITY ||
executorModel !== "" ||
@@ -321,30 +319,9 @@ export function TaskForm({
}
}, [isActive, settings, availablePresets, mode]);
// Auto-select defaultOn workflow steps (create mode, once per activation)
const defaultOnAppliedRef = useRef(false);
// U6/R3: the workflow picker preselects the project default (undefined →
// "(default)"); there is no longer a per-step defaultOn auto-select effect.
const githubTrackingDefaultAppliedRef = useRef(false);
useEffect(() => {
if (mode !== "create" || !isActive) return;
if (defaultOnAppliedRef.current) return;
if (workflowSteps.length === 0) return;
const defaultOnSteps = workflowSteps.filter((s) => s.defaultOn);
if (defaultOnSteps.length === 0) return;
defaultOnAppliedRef.current = true;
const stepIds = defaultOnSteps.map((s) => s.id);
onWorkflowStepsChange(stepIds);
onDefaultOnApplied?.(stepIds);
}, [mode, isActive, workflowSteps]);
// Reset defaultOn tracking when form deactivates
useEffect(() => {
if (!isActive) {
defaultOnAppliedRef.current = false;
}
}, [isActive]);
useEffect(() => {
if (mode !== "create" || !isActive) return;
if (!onGithubTrackingEnabledChange) return;
@@ -637,30 +614,8 @@ export function TaskForm({
}
}, [favoriteModels, favoriteProviders]);
// Workflow step reorder helpers
const moveWorkflowStepUp = useCallback((index: number) => {
if (index <= 0) return;
const updated = [...selectedWorkflowSteps];
[updated[index - 1], updated[index]] = [updated[index], updated[index - 1]];
onWorkflowStepsChange(updated);
}, [selectedWorkflowSteps, onWorkflowStepsChange]);
const moveWorkflowStepDown = useCallback((index: number) => {
if (index >= selectedWorkflowSteps.length - 1) return;
const updated = [...selectedWorkflowSteps];
[updated[index], updated[index + 1]] = [updated[index + 1], updated[index]];
onWorkflowStepsChange(updated);
}, [selectedWorkflowSteps, onWorkflowStepsChange]);
const removeWorkflowStep = useCallback((stepId: string) => {
onWorkflowStepsChange(selectedWorkflowSteps.filter((id) => id !== stepId));
}, [selectedWorkflowSteps, onWorkflowStepsChange]);
// Build a lookup for step names.
const workflowStepLookup = new Map<string, { name: string; description: string }>();
for (const step of workflowSteps) {
workflowStepLookup.set(step.id, { name: step.name, description: step.description });
}
// U6/R3: the project default workflow id (preselected + "(default)" badged).
const defaultWorkflowId = settings?.defaultWorkflowId ?? null;
const availableDeps = tasks
.filter((t) => !dependencies.includes(t.id))
@@ -1326,94 +1281,63 @@ export function TaskForm({
{renderBelowModelConfiguration}
{/* Workflow Steps */}
<div className="form-group" data-testid="workflow-steps-section">
<label>{t("taskForm.workflowStepsLabel", "Workflow Steps")}</label>
<div className="workflow-steps-section">
<small className="workflow-steps-description">
{t("taskForm.workflowStepsDescription", "Select steps to run after task implementation completes")}
{/* Workflow picker (U6/R3). A task picks a whole workflow at creation; the
selection is materialized atomically server-side via `workflowId`. */}
{onWorkflowIdChange && (
<div className="form-group" data-testid="workflow-steps-section">
<label htmlFor="task-workflow-select">{t("taskForm.workflowLabel", "Workflow")}</label>
{workflowsLoading ? (
<div className="workflow-select-loading" data-testid="task-workflow-loading">
{t("taskForm.workflowsLoading", "Loading workflows…")}
</div>
) : workflows.length === 0 ? (
// Built-ins are always present, so an empty list means the fetch
// failed. No editor-open prop is plumbed through TaskForm, so we
// surface a plain-text CTA rather than inventing new prop wiring.
<div className="workflow-select-cta" data-testid="task-workflow-cta">
{t("taskForm.workflowsCta", "Set up workflows in the editor")}
</div>
) : (
<select
id="task-workflow-select"
data-testid="task-workflow-select"
value={
selectedWorkflowId === null
? "__none__"
: selectedWorkflowId === undefined
// Inherit project default: show the default option preselected,
// or "No workflow" when no project default is configured.
? (defaultWorkflowId ?? "__none__")
: selectedWorkflowId
}
disabled={disabled}
onChange={(e) => {
const next = e.target.value;
onWorkflowIdChange(next === "__none__" ? null : next);
}}
>
{/* "No workflow" listed FIRST (maps to null → explicit opt-out). */}
<option value="__none__">{t("taskForm.workflowNone", "No workflow")}</option>
{/* Project default preselected + badged when configured. */}
{defaultWorkflowId && workflows.some((w) => w.id === defaultWorkflowId) && (
<option value={defaultWorkflowId}>
{`${workflows.find((w) => w.id === defaultWorkflowId)?.name ?? defaultWorkflowId} ${t("taskForm.workflowDefaultBadge", "(default)")}`}
</option>
)}
{workflows
.filter((w) => w.id !== defaultWorkflowId)
.map((w) => (
<option key={w.id} value={w.id}>
{w.name}
</option>
))}
</select>
)}
<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>
<div className="workflow-steps-list">
{workflowSteps.length > 0 && workflowSteps.map((step) => (
<label
key={step.id}
className="checkbox-label workflow-step-item"
data-testid={`workflow-step-checkbox-${step.id}`}
>
<input
type="checkbox"
checked={selectedWorkflowSteps.includes(step.id)}
onChange={(e) => {
onWorkflowStepsChange(
e.target.checked
? [...selectedWorkflowSteps, step.id]
: selectedWorkflowSteps.filter((id) => id !== step.id)
);
}}
disabled={disabled}
/>
<div>
<span className="workflow-step-name">
{step.name}
{phaseBadge(step.phase || "pre-merge", step.id, "workflow-step-phase", t)}
</span>
<div className="workflow-step-description">
{step.description}
</div>
</div>
</label>
))}
</div>
</div>
{/* Selected steps — execution order with reorder controls */}
{selectedWorkflowSteps.length > 1 && (
<div className="workflow-step-order" data-testid="workflow-step-order">
<small className="workflow-step-order-label">{t("taskForm.executionOrderLabel", "Execution order:")}</small>
{selectedWorkflowSteps.map((stepId, index) => {
const stepInfo = workflowStepLookup.get(stepId);
return (
<div key={stepId} className="workflow-step-order-item" data-testid={`workflow-step-order-item-${stepId}`}>
<span className="workflow-step-order-number">{index + 1}</span>
<span className="workflow-step-order-name">{stepInfo?.name || stepId}</span>
<div className="workflow-step-order-actions">
<button
type="button"
className="btn btn-icon btn-sm"
onClick={() => moveWorkflowStepUp(index)}
disabled={disabled || index === 0}
data-testid={`workflow-step-move-up-${stepId}`}
title={t("taskForm.moveUp", "Move up")}
>
<ChevronUp />
</button>
<button
type="button"
className="btn btn-icon btn-sm"
onClick={() => moveWorkflowStepDown(index)}
disabled={disabled || index === selectedWorkflowSteps.length - 1}
data-testid={`workflow-step-move-down-${stepId}`}
title={t("taskForm.moveDown", "Move down")}
>
<ChevronDown />
</button>
<button
type="button"
className="btn btn-icon btn-sm"
onClick={() => removeWorkflowStep(stepId)}
disabled={disabled}
data-testid={`workflow-step-remove-${stepId}`}
title={t("taskForm.removeStep", "Remove")}
>
<X />
</button>
</div>
</div>
);
})}
</div>
)}
</div>
)}
{(onGithubTrackingEnabledChange || onGithubRepoOverrideChange) && (
<div className="form-group" data-testid="task-form-github-tracking">

View File

@@ -16,11 +16,6 @@ vi.mock("lucide-react", () => ({
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
vi.mock("../../api", () => ({
uploadAttachment: vi.fn().mockResolvedValue({}),
@@ -33,7 +28,9 @@ vi.mock("../../api", () => ({
autoSelectModelPreset: false,
defaultPresetBySize: {},
}),
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
// U6/R3: TaskForm's picker fetches whole workflows; the per-step
// fetchWorkflowSteps + post-create selectTaskWorkflow flow is gone.
fetchWorkflows: vi.fn().mockResolvedValue([]),
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
fetchGitBranches: vi.fn().mockResolvedValue([]),
fetchAgents: vi.fn().mockResolvedValue([]),
@@ -41,7 +38,6 @@ 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();
@@ -165,10 +161,10 @@ describe("NewTaskModal", () => {
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(moreOptions).not.toHaveAttribute("hidden");
});
// Model Configuration, Attachments, and Workflow Steps are revealed
// Model Configuration, Attachments, and the Workflow picker are revealed
expect(screen.getByText(/Model Configuration/i)).toBeTruthy();
expect(screen.getByText(/Attachments/i)).toBeTruthy();
expect(screen.getByText(/Workflow Steps/i)).toBeTruthy();
expect(screen.getByText("Workflow")).toBeTruthy();
});
it("shows dependencies and agent picker by default without expanding More options", () => {
@@ -595,227 +591,91 @@ describe("NewTaskModal", () => {
});
// Workflow step ordering tests (FN-836)
describe("workflow step ordering", () => {
it("sends selected enabledWorkflowSteps in create payload", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
]);
describe("workflow selection (U6/R3)", () => {
function mockWorkflows(defs: Array<{ id: string; name: string; kind?: "workflow" | "fragment" }>) {
return import("../../api").then(({ fetchWorkflows }) => {
vi.mocked(fetchWorkflows).mockResolvedValueOnce(
defs.map((d) => ({
id: d.id,
name: d.name,
description: "",
kind: d.kind ?? "workflow",
ir: { version: "v1", name: d.name, nodes: [], edges: [] },
layout: {},
createdAt: "",
updatedAt: "",
})) as any,
);
});
}
it("omits workflowId from the payload when the picker is untouched (inherit default)", async () => {
await mockWorkflows([{ id: "WF-1", name: "QA" }]);
const { props } = renderNewTaskModal();
await waitFor(() => {
expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy();
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
});
const checkbox = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector('input[type="checkbox"]') as HTMLInputElement;
fireEvent.click(checkbox);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Inherit default" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
fireEvent.change(screen.getByRole('textbox'), { target: { value: "Task with workflow step" } });
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalled();
});
const payload = vi.mocked(props.onCreateTask).mock.calls[0][0] as Record<string, unknown>;
expect("workflowId" in payload).toBe(false);
});
it("sends the chosen workflowId in the create payload", async () => {
await mockWorkflows([{ id: "WF-1", name: "QA" }]);
const { props } = renderNewTaskModal();
await waitFor(() => {
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
});
fireEvent.change(screen.getByTestId("task-workflow-select"), { target: { value: "WF-1" } });
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Pick a workflow" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
enabledWorkflowSteps: ["WS-001"],
}),
expect.objectContaining({ workflowId: "WF-1" }),
);
});
});
it("sends ordered enabledWorkflowSteps in create payload when steps are selected in order", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
]);
it("sends workflowId: null when 'No workflow' is chosen", async () => {
await mockWorkflows([{ id: "WF-1", name: "QA" }]);
const { props } = renderNewTaskModal();
await waitFor(() => {
expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy();
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
});
// Select WS-001, then WS-002 — order should be preserved
const checkbox1 = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector('input[type="checkbox"]') as HTMLInputElement;
fireEvent.click(checkbox1);
const checkbox2 = screen.getByTestId("workflow-step-checkbox-WS-002").querySelector('input[type="checkbox"]') as HTMLInputElement;
fireEvent.click(checkbox2);
// Type description and submit
fireEvent.change(screen.getByRole('textbox'), { target: { value: "Ordered task" } });
// Pick a workflow, then switch to "No workflow" to register an explicit null.
fireEvent.change(screen.getByTestId("task-workflow-select"), { target: { value: "WF-1" } });
fireEvent.change(screen.getByTestId("task-workflow-select"), { target: { value: "__none__" } });
fireEvent.change(screen.getByRole("textbox"), { target: { value: "No workflow task" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
enabledWorkflowSteps: ["WS-001", "WS-002"],
}),
expect.objectContaining({ workflowId: null }),
);
});
});
it("sends reordered enabledWorkflowSteps after user reorders steps", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
]);
const { props } = renderNewTaskModal();
await waitFor(() => {
expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy();
});
// Select WS-001, then WS-002
const checkbox1 = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector('input[type="checkbox"]') as HTMLInputElement;
fireEvent.click(checkbox1);
const checkbox2 = screen.getByTestId("workflow-step-checkbox-WS-002").querySelector('input[type="checkbox"]') as HTMLInputElement;
fireEvent.click(checkbox2);
// Now reorder: move WS-002 up
await waitFor(() => {
expect(screen.getByTestId("workflow-step-move-up-WS-002")).toBeTruthy();
});
fireEvent.click(screen.getByTestId("workflow-step-move-up-WS-002"));
// Type description and submit
fireEvent.change(screen.getByRole('textbox'), { target: { value: "Reordered task" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
enabledWorkflowSteps: ["WS-002", "WS-001"],
}),
);
});
});
});
// DefaultOn workflow step handling (FN-883)
describe("defaultOn workflow step handling", () => {
it("keeps More options collapsed by default even when defaultOn workflow steps are auto-applied", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, defaultOn: true, createdAt: "", updatedAt: "" },
]);
it("does not render the legacy per-step checkbox UI", async () => {
await mockWorkflows([{ id: "WF-1", name: "QA" }]);
renderNewTaskModal();
await waitFor(() => {
expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy();
});
const toggle = screen.getByTestId("task-form-more-options-toggle");
expect(toggle).toHaveAttribute("aria-expanded", "false");
fireEvent.click(toggle);
await waitFor(() => {
expect(toggle).toHaveAttribute("aria-expanded", "true");
});
expect(screen.getByText(/Model Configuration/i)).toBeTruthy();
expect(screen.getByText(/Attachments/i)).toBeTruthy();
expect(screen.getByText(/Workflow Steps/i)).toBeTruthy();
});
it("sends undefined enabledWorkflowSteps when no defaultOn steps and user hasn't interacted", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
]);
const { props } = renderNewTaskModal();
await waitFor(() => {
expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy();
});
// Don't interact with workflow steps at all
fireEvent.change(screen.getByRole('textbox'), { target: { value: "No interaction task" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
enabledWorkflowSteps: undefined,
}),
);
});
});
it("sends empty array when user explicitly deselects all defaultOn steps", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, defaultOn: true, createdAt: "", updatedAt: "" },
]);
const { props } = renderNewTaskModal();
await waitFor(() => {
expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy();
});
// Wait for auto-selection to happen
await waitFor(() => {
const checkbox = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
});
// User explicitly deselects the auto-selected step
const checkbox = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector('input[type="checkbox"]') as HTMLInputElement;
fireEvent.click(checkbox);
fireEvent.change(screen.getByRole('textbox'), { target: { value: "Deselected task" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
enabledWorkflowSteps: [],
}),
);
});
});
it("sends defaultOn step IDs when user doesn't modify the auto-selected steps", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, defaultOn: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Security", description: "Check security", prompt: "Check", mode: "prompt" as const, enabled: true, defaultOn: false, createdAt: "", updatedAt: "" },
]);
const { props } = renderNewTaskModal();
await waitFor(() => {
expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy();
});
// Wait for auto-selection to happen
await waitFor(() => {
const checkbox = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
});
// Don't modify the selection — just submit.
// Since user hasn't explicitly changed steps, the explicitlySet flag is false,
// so the modal sends undefined (backend applies its own defaults)
fireEvent.change(screen.getByRole('textbox'), { target: { value: "Auto-selected task" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
enabledWorkflowSteps: undefined,
}),
);
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
});
expect(screen.queryByTestId("workflow-step-order")).toBeNull();
expect(document.querySelector('[data-testid^="workflow-step-checkbox-"]')).toBeNull();
});
});
@@ -1195,12 +1055,12 @@ describe("NewTaskModal", () => {
});
describe("GitHub tracking", () => {
it("renders GitHub tracking after Workflow Steps in more options", async () => {
it("renders GitHub tracking after the Workflow picker in more options", async () => {
renderNewTaskModal();
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
const workflowLabel = await screen.findByText("Workflow Steps");
const workflowLabel = await screen.findByText("Workflow");
const githubTrackingSection = screen.getByTestId("task-form-github-tracking");
expect(

View File

@@ -236,14 +236,12 @@ describe("TaskDetailModal", () => {
await waitFor(() => {
const modelLabel = screen.getByText("Model Configuration");
const sourceLabel = screen.getByText("Source Issue");
const workflowSection = screen.getByTestId("workflow-steps-section");
// U6/R3: the per-step workflow section no longer renders in edit mode,
// so we only assert Source Issue stays below Model Configuration.
expect(
modelLabel.compareDocumentPosition(sourceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(
sourceLabel.compareDocumentPosition(workflowSection) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
});
});
@@ -734,9 +732,11 @@ describe("TaskDetailModal", () => {
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
// Model configuration and workflow steps should be present via TaskForm
// Model configuration is present via TaskForm in edit mode.
// U6/R3: the per-step "Workflow Steps" section was removed from TaskForm;
// workflow management for an existing task lives in the Workflow tab.
expect(screen.getByText(/Model Configuration/i)).toBeTruthy();
expect(screen.getByText(/Workflow Steps/i)).toBeTruthy();
expect(screen.queryByText(/Workflow Steps/i)).toBeNull();
});
it("save sends only changed fields via updateTask", async () => {
@@ -1611,57 +1611,6 @@ describe("TaskDetailModal", () => {
});
describe("Workflow step ordering in edit mode (FN-836)", () => {
it("sends ordered enabledWorkflowSteps when saving with reordered steps", async () => {
const { updateTask, fetchWorkflowSteps } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
]);
const { container } = render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "triage",
title: "Test",
description: "Desc",
enabledWorkflowSteps: ["WS-001", "WS-002"],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
// Wait for workflow steps to load and reorder controls to appear
await waitFor(() => {
expect(screen.getByTestId("workflow-step-order")).toBeTruthy();
});
// Move WS-002 up (swap with WS-001)
fireEvent.click(screen.getByTestId("workflow-step-move-up-WS-002"));
// Save
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", expect.objectContaining({
enabledWorkflowSteps: ["WS-002", "WS-001"],
}), undefined);
});
});
});
describe("agent assignment", () => {
it("shows Assign Agent button when task has no assigned agent", () => {
render(

View File

@@ -916,57 +916,6 @@ describe("TaskDetailModal", () => {
});
});
});
describe("Workflow step ordering in edit mode (FN-836)", () => {
it("sends ordered enabledWorkflowSteps when saving with reordered steps", async () => {
const { updateTask, fetchWorkflowSteps } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
]);
const { container } = render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "triage",
title: "Test",
description: "Desc",
enabledWorkflowSteps: ["WS-001", "WS-002"],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
// Wait for workflow steps to load and reorder controls to appear
await waitFor(() => {
expect(screen.getByTestId("workflow-step-order")).toBeTruthy();
});
// Move WS-002 up (swap with WS-001)
fireEvent.click(screen.getByTestId("workflow-step-move-up-WS-002"));
// Save
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", expect.objectContaining({
enabledWorkflowSteps: ["WS-002", "WS-001"],
}), undefined);
});
});
});
describe("Workflow tab", () => {
it.each<[string, Parameters<typeof makeTask>[0]]>([
["empty enabledWorkflowSteps", { enabledWorkflowSteps: [] }],

View File

@@ -25,7 +25,8 @@ vi.mock("../../api", () => ({
autoSelectModelPreset: false,
defaultPresetBySize: {},
}),
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
// U6/R3: TaskForm now fetches whole workflows (not steps) for the picker.
fetchWorkflows: 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."),
@@ -64,8 +65,8 @@ function renderTaskForm(props: Partial<React.ComponentProps<typeof TaskForm>> =
onPresetModeChange: vi.fn(),
selectedPresetId: "",
onSelectedPresetIdChange: vi.fn(),
selectedWorkflowSteps: [],
onWorkflowStepsChange: vi.fn(),
selectedWorkflowId: undefined,
onWorkflowIdChange: vi.fn(),
pendingImages: [],
onImagesChange: vi.fn(),
tasks: [],
@@ -96,8 +97,6 @@ function renderTaskFormWithDescriptionState(props: Partial<React.ComponentProps<
onPresetModeChange: vi.fn(),
selectedPresetId: "",
onSelectedPresetIdChange: vi.fn(),
selectedWorkflowSteps: [],
onWorkflowStepsChange: vi.fn(),
pendingImages: [],
onImagesChange: vi.fn(),
tasks: [],
@@ -642,33 +641,31 @@ describe("TaskForm", () => {
expect(container.querySelector(".inline-create-previews")).toBeTruthy();
});
it("calls onWorkflowStepsChange when a fetched workflow step is toggled", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
it("renders a fetched workflow as a dropdown option (U6/R3)", async () => {
const { fetchWorkflows } = await import("../../api");
vi.mocked(fetchWorkflows).mockResolvedValueOnce([
{
id: "WS-005",
id: "WF-1",
name: "Browser Verification",
description: "Verify in browser",
prompt: "Run browser verification",
templateId: "browser-verification",
mode: "prompt" as const,
enabled: true,
kind: "workflow",
ir: { version: "v1", name: "Browser Verification", nodes: [], edges: [] },
layout: {},
createdAt: "",
updatedAt: "",
},
]);
] as any);
const onWorkflowStepsChange = vi.fn();
renderTaskForm({ onWorkflowStepsChange });
const onWorkflowIdChange = vi.fn();
renderTaskForm({ onWorkflowIdChange });
await waitFor(() => {
expect(screen.getByTestId("workflow-step-checkbox-WS-005")).toBeTruthy();
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
});
const checkbox = screen.getByTestId("workflow-step-checkbox-WS-005").querySelector('input[type="checkbox"]') as HTMLInputElement;
fireEvent.click(checkbox);
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-005"]);
const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "WF-1" } });
expect(onWorkflowIdChange).toHaveBeenCalledWith("WF-1");
});
it("disables all inputs when disabled prop is true", () => {
@@ -1004,405 +1001,129 @@ describe("TaskForm preset selection (FN-819)", () => {
});
});
describe("TaskForm workflow step reordering (FN-836)", () => {
describe("TaskForm workflow picker (U6/R3)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("does not show reorder controls when no steps are selected", () => {
renderTaskForm({ selectedWorkflowSteps: [] });
expect(screen.queryByTestId("workflow-step-order")).toBeNull();
});
it("does not show reorder controls when only one step is selected", () => {
renderTaskForm({ selectedWorkflowSteps: ["WS-001"] });
expect(screen.queryByTestId("workflow-step-order")).toBeNull();
});
it("shows reorder controls when two or more steps are selected", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
]);
renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002"] });
await waitFor(() => {
expect(screen.getByTestId("workflow-step-order")).toBeTruthy();
});
expect(screen.getByTestId("workflow-step-order-item-WS-001")).toBeTruthy();
expect(screen.getByTestId("workflow-step-order-item-WS-002")).toBeTruthy();
});
it("shows numbered execution order", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
]);
renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002"] });
await waitFor(() => {
expect(screen.getByText("1")).toBeTruthy();
expect(screen.getByText("2")).toBeTruthy();
});
});
it("disables move-up button on first step", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
]);
renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002"] });
await waitFor(() => {
expect(screen.getByTestId("workflow-step-order")).toBeTruthy();
});
const moveUpFirst = screen.getByTestId("workflow-step-move-up-WS-001") as HTMLButtonElement;
expect(moveUpFirst.disabled).toBe(true);
});
it("disables move-down button on last step", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
]);
renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002"] });
await waitFor(() => {
expect(screen.getByTestId("workflow-step-order")).toBeTruthy();
});
const moveDownLast = screen.getByTestId("workflow-step-move-down-WS-002") as HTMLButtonElement;
expect(moveDownLast.disabled).toBe(true);
});
it("calls onWorkflowStepsChange with swapped order when move-up is clicked", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
]);
const onWorkflowStepsChange = vi.fn();
renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002"], onWorkflowStepsChange });
await waitFor(() => {
expect(screen.getByTestId("workflow-step-order")).toBeTruthy();
});
// Move WS-002 up (swap with WS-001)
fireEvent.click(screen.getByTestId("workflow-step-move-up-WS-002"));
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-002", "WS-001"]);
});
it("calls onWorkflowStepsChange with swapped order when move-down is clicked", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
]);
const onWorkflowStepsChange = vi.fn();
renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002"], onWorkflowStepsChange });
await waitFor(() => {
expect(screen.getByTestId("workflow-step-order")).toBeTruthy();
});
// Move WS-001 down (swap with WS-002)
fireEvent.click(screen.getByTestId("workflow-step-move-down-WS-001"));
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-002", "WS-001"]);
});
it("calls onWorkflowStepsChange with step removed when remove button is clicked", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
]);
const onWorkflowStepsChange = vi.fn();
renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002"], onWorkflowStepsChange });
await waitFor(() => {
expect(screen.getByTestId("workflow-step-order")).toBeTruthy();
});
// Remove WS-001
fireEvent.click(screen.getByTestId("workflow-step-remove-WS-001"));
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-002"]);
});
it("falls back to raw step ID in reorder list when metadata is missing", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
]);
renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-999"] });
await waitFor(() => {
expect(screen.getByTestId("workflow-step-order")).toBeTruthy();
});
const orderItem1 = screen.getByTestId("workflow-step-order-item-WS-001");
const orderItem2 = screen.getByTestId("workflow-step-order-item-WS-999");
// Step metadata can resolve slightly after initial render under heavy suite load.
// Accept either the friendly name (preferred) or raw ID fallback.
expect(orderItem1.textContent).toMatch(/QA Check|WS-001/);
expect(orderItem2.textContent).toContain("WS-999");
});
it("preserves order when adding a new step via checkbox after reorder", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
{ id: "WS-003", name: "Doc Review", description: "Check docs", prompt: "Check docs", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
]);
const onWorkflowStepsChange = vi.fn();
// Start with WS-001, WS-002
renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002"], onWorkflowStepsChange });
await waitFor(() => {
expect(screen.getByTestId("workflow-step-order")).toBeTruthy();
});
// Click checkbox to add WS-003 — it should be appended
const checkboxRow = await screen.findByTestId("workflow-step-checkbox-WS-003");
const checkbox = checkboxRow.querySelector('input[type="checkbox"]') as HTMLInputElement;
fireEvent.click(checkbox);
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-001", "WS-002", "WS-003"]);
});
it("preserves order when removing a step via remove action (not reorder remove)", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
{ id: "WS-003", name: "Doc Review", description: "Check docs", prompt: "Check docs", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
]);
const onWorkflowStepsChange = vi.fn();
renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002", "WS-003"], onWorkflowStepsChange });
await waitFor(() => {
expect(screen.getByTestId("workflow-step-order")).toBeTruthy();
});
// Remove WS-002 via explicit remove action
fireEvent.click(screen.getByTestId("workflow-step-remove-WS-002"));
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-001", "WS-003"]);
});
it("shows phase badge for workflow steps with phase info", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, phase: "pre-merge", enabled: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Post-merge Notify", description: "Notify team", prompt: "Notify", mode: "prompt" as const, phase: "post-merge", enabled: true, createdAt: "", updatedAt: "" },
]);
renderTaskForm({ selectedWorkflowSteps: [] });
await waitFor(() => {
expect(screen.getByTestId("workflow-step-phase-WS-001")).toHaveTextContent("Pre-merge");
expect(screen.getByTestId("workflow-step-phase-WS-002")).toHaveTextContent("Post-merge");
});
});
it("shows Pre-merge phase badge for legacy steps without phase", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "Legacy Check", description: "No phase field", prompt: "Check", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
]);
renderTaskForm({ selectedWorkflowSteps: [] });
await waitFor(() => {
expect(screen.getByTestId("workflow-step-phase-WS-001")).toHaveTextContent("Pre-merge");
});
});
});
describe("TaskForm defaultOn auto-selection (FN-883)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("auto-selects defaultOn workflow steps in create mode", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, defaultOn: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, defaultOn: false, createdAt: "", updatedAt: "" },
]);
const onWorkflowStepsChange = vi.fn();
const onDefaultOnApplied = vi.fn();
renderTaskForm({
mode: "create",
onWorkflowStepsChange,
onDefaultOnApplied,
});
await waitFor(() => {
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-001"]);
});
expect(onDefaultOnApplied).toHaveBeenCalledWith(["WS-001"]);
});
it("auto-expands More options by default when advanced selections are prefilled", () => {
renderTaskForm({
mode: "create",
selectedWorkflowSteps: ["WS-001"],
});
expect(screen.getByTestId("task-form-more-options-toggle")).toHaveAttribute("aria-expanded", "true");
});
it("stays collapsed when auto-expand is disabled even with prefilled advanced selections", () => {
renderTaskForm({
mode: "create",
selectedWorkflowSteps: ["WS-001"],
autoExpandMoreOptionsOnSelection: false,
});
expect(screen.getByTestId("task-form-more-options-toggle")).toHaveAttribute("aria-expanded", "false");
});
it("hides advanced controls from interaction when More options is collapsed", () => {
renderTaskForm({ mode: "create" });
const toggle = screen.getByTestId("task-form-more-options-toggle");
const moreOptions = screen.getByTestId("task-form-more-options");
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(moreOptions).toHaveAttribute("hidden");
expect(moreOptions).toHaveAttribute("aria-hidden", "true");
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(moreOptions).not.toHaveAttribute("hidden");
expect(moreOptions).toHaveAttribute("aria-hidden", "false");
});
it("does not auto-select workflow steps in edit mode", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, defaultOn: true, createdAt: "", updatedAt: "" },
]);
const onWorkflowStepsChange = vi.fn();
renderTaskForm({
mode: "edit",
title: "Test",
onTitleChange: vi.fn(),
onWorkflowStepsChange,
});
// Wait for workflow steps to load
await waitFor(() => {
expect(fetchWorkflowSteps).toHaveBeenCalled();
});
// Should NOT have called onWorkflowStepsChange with defaults
expect(onWorkflowStepsChange).not.toHaveBeenCalled();
});
it("does not re-apply defaults after user changes workflow steps", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, defaultOn: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, defaultOn: false, createdAt: "", updatedAt: "" },
]);
let currentSteps: string[] = [];
const onWorkflowStepsChange = vi.fn((steps: string[]) => {
currentSteps = steps;
});
const { rerender } = renderTaskForm({
mode: "create",
selectedWorkflowSteps: currentSteps,
onWorkflowStepsChange,
});
// Wait for auto-selection
await waitFor(() => {
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-001"]);
});
// Simulate parent state update by rerendering with new steps
rerender(
<TaskForm
mode="create"
description=""
onDescriptionChange={vi.fn()}
dependencies={[]}
onDependenciesChange={vi.fn()}
executorModel=""
onExecutorModelChange={vi.fn()}
validatorModel=""
onValidatorModelChange={vi.fn()}
presetMode="default"
onPresetModeChange={vi.fn()}
selectedPresetId=""
onSelectedPresetIdChange={vi.fn()}
selectedWorkflowSteps={currentSteps}
onWorkflowStepsChange={onWorkflowStepsChange}
pendingImages={[]}
onImagesChange={vi.fn()}
tasks={[]}
addToast={vi.fn()}
isActive={true}
/>
async function mockWorkflows(defs: Array<{ id: string; name: string; kind?: "workflow" | "fragment" }>) {
const { fetchWorkflows } = await import("../../api");
vi.mocked(fetchWorkflows).mockResolvedValueOnce(
defs.map((d) => ({
id: d.id,
name: d.name,
description: "",
kind: d.kind ?? "workflow",
ir: { version: "v1", name: d.name, nodes: [], edges: [] },
layout: {},
createdAt: "",
updatedAt: "",
})) as any,
);
}
// Clear the mock to track further calls
onWorkflowStepsChange.mockClear();
// Simulate user toggling WS-002 checkbox
const checkbox = screen.getByTestId("workflow-step-checkbox-WS-002").querySelector('input[type="checkbox"]') as HTMLInputElement;
fireEvent.click(checkbox);
// Should have been called with user action (adding WS-002 to existing WS-001)
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-001", "WS-002"]);
// No additional auto-selection calls
expect(onWorkflowStepsChange).toHaveBeenCalledTimes(1);
});
it("does not auto-select when no steps have defaultOn", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
]);
const onWorkflowStepsChange = vi.fn();
renderTaskForm({
mode: "create",
onWorkflowStepsChange,
});
it("renders the dropdown with 'No workflow' first and the help text", async () => {
await mockWorkflows([{ id: "WF-1", name: "QA" }]);
renderTaskForm({ onWorkflowIdChange: vi.fn() });
await waitFor(() => {
expect(fetchWorkflowSteps).toHaveBeenCalled();
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
});
const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement;
expect(select.options[0].textContent).toBe("No workflow");
expect(screen.getByTestId("task-workflow-help")).toBeTruthy();
});
// Should NOT have called onWorkflowStepsChange
expect(onWorkflowStepsChange).not.toHaveBeenCalled();
it("badges the project default workflow with (default)", async () => {
const { fetchSettings } = await import("../../api");
vi.mocked(fetchSettings).mockResolvedValueOnce({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
defaultWorkflowId: "WF-1",
} as any);
await mockWorkflows([
{ id: "WF-1", name: "QA" },
{ id: "WF-2", name: "Docs" },
]);
renderTaskForm({ onWorkflowIdChange: vi.fn() });
await waitFor(() => {
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
});
expect(screen.getByText("QA (default)")).toBeTruthy();
});
it("excludes fragments from the dropdown", async () => {
await mockWorkflows([
{ id: "WF-1", name: "QA", kind: "workflow" },
{ id: "FRAG-1", name: "Doc Fragment", kind: "fragment" },
]);
renderTaskForm({ onWorkflowIdChange: vi.fn() });
await waitFor(() => {
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
});
const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement;
const labels = Array.from(select.options).map((o) => o.textContent);
expect(labels).toContain("QA");
expect(labels).not.toContain("Doc Fragment");
});
it("passes the chosen workflow id via onWorkflowIdChange", async () => {
await mockWorkflows([{ id: "WF-1", name: "QA" }]);
const onWorkflowIdChange = vi.fn();
renderTaskForm({ onWorkflowIdChange });
await waitFor(() => {
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
});
const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "WF-1" } });
expect(onWorkflowIdChange).toHaveBeenCalledWith("WF-1");
});
it("maps 'No workflow' to null", async () => {
await mockWorkflows([{ id: "WF-1", name: "QA" }]);
const onWorkflowIdChange = vi.fn();
renderTaskForm({ onWorkflowIdChange, selectedWorkflowId: "WF-1" });
await waitFor(() => {
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
});
const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "__none__" } });
expect(onWorkflowIdChange).toHaveBeenCalledWith(null);
});
it("shows a loading placeholder while workflows load", async () => {
const { fetchWorkflows } = await import("../../api");
let resolveFn: (v: unknown) => void = () => {};
vi.mocked(fetchWorkflows).mockReturnValueOnce(
new Promise((resolve) => {
resolveFn = resolve;
}) as any,
);
renderTaskForm({ onWorkflowIdChange: vi.fn() });
expect(screen.getByTestId("task-workflow-loading")).toBeTruthy();
resolveFn([]);
});
it("regression: no per-step checkboxes and no fetchWorkflowSteps usage", async () => {
await mockWorkflows([{ id: "WF-1", name: "QA" }]);
renderTaskForm({ onWorkflowIdChange: vi.fn() });
await waitFor(() => {
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
});
// The old per-step checkbox UI and execution-order controls are gone.
expect(screen.queryByTestId("workflow-step-order")).toBeNull();
expect(document.querySelector('[data-testid^="workflow-step-checkbox-"]')).toBeNull();
// The api mock no longer needs a fetchWorkflowSteps export — TaskForm never
// calls it. (If it still did, rendering above would have thrown on the
// missing mock export, so reaching this point is itself the regression proof.)
});
});
@@ -1511,7 +1232,8 @@ describe("TaskForm focus behavior (FN-1459)", () => {
});
const modelLabel = screen.getByText("Model Configuration");
const workflowLabel = screen.getByText("Workflow Steps");
// U6/R3: the per-step "Workflow Steps" section is now the "Workflow" picker.
const workflowLabel = screen.getByText("Workflow");
const injectedBottom = screen.getByTestId("injected-below-model");
const githubTrackingSection = screen.getByTestId("task-form-github-tracking");
@@ -1540,7 +1262,9 @@ describe("TaskForm focus behavior (FN-1459)", () => {
// Dependencies label and dep-trigger should not be in the document
expect(screen.queryByText("Dependencies")).toBeNull();
expect(screen.queryByRole("button", { name: /Add dependencies/i })).toBeNull();
expect(screen.queryByText(/selected/i)).toBeNull();
// The dependency "N selected" count must be absent (avoid matching the
// workflow picker's help copy, which also contains the word "selected").
expect(screen.queryByText(/\d+ selected/i)).toBeNull();
});
it("does not auto-expand More options for dependency selections when hideDependencies is true", async () => {

View File

@@ -0,0 +1,130 @@
// @vitest-environment node
//
// U6/R3/KTD-4: HTTP integration coverage for the create-time `workflowId`
// parameter on POST /tasks. Exercises the route end-to-end against a REAL
// TaskStore via createApiRoutes:
// - workflowId → task's enabledWorkflowSteps populated atomically (the
// materialization happens inside createTask, not via a post-create select)
// - fragment id → 4xx (rejected before the task row is created)
// - unknown id → 4xx
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { TaskStore } from "@fusion/core";
import type { WorkflowIr } from "@fusion/core";
import { createApiRoutes } from "../../routes.js";
import { request as REQUEST } from "../../test-request.js";
/** Linear v1 workflow with two pre-merge steps that compiles + selects cleanly. */
function linearIr(name: string): WorkflowIr {
return {
version: "v1",
name,
nodes: [
{ id: "start", kind: "start" },
{ id: "lint", kind: "gate", config: { name: "Lint", scriptName: "lint" } },
{ id: "spec", kind: "prompt", config: { name: "Spec", prompt: "check" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "lint", condition: "success" },
{ from: "lint", to: "spec", condition: "success" },
{ from: "spec", to: "end", condition: "success" },
],
};
}
/** Single-node fragment IR (not selectable for a task). */
function fragmentIr(): WorkflowIr {
return {
version: "v1",
name: "frag",
nodes: [
{ id: "start", kind: "start" },
{ id: "step-1", kind: "prompt", config: { name: "Doc", prompt: "doc it" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "step-1", condition: "success" },
{ from: "step-1", to: "end", condition: "success" },
],
};
}
describe("POST /tasks workflowId (U6/R3)", () => {
let store: TaskStore;
let rootDir: string;
let globalDir: string;
let app: express.Express;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "task-wf-route-root-"));
globalDir = mkdtempSync(join(tmpdir(), "task-wf-route-global-"));
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
});
afterEach(() => {
store.close();
rmSync(rootDir, { recursive: true, force: true });
rmSync(globalDir, { recursive: true, force: true });
});
const post = (path: string, body: unknown) =>
REQUEST(app, "POST", path, JSON.stringify(body), { "content-type": "application/json" });
it("workflowId → created task has populated enabledWorkflowSteps", async () => {
const wf = await store.createWorkflowDefinition({ name: "QA", ir: linearIr("qa") });
const res = await post("/api/tasks", { description: "with workflow", workflowId: wf.id });
expect(res.status).toBe(201);
const created = res.body as { id: string };
const detail = await store.getTask(created.id);
expect(detail.enabledWorkflowSteps).toHaveLength(2);
expect(store.getTaskWorkflowSelection(created.id)?.workflowId).toBe(wf.id);
});
it("workflowId: null → task created with no workflow steps", async () => {
const def = await store.createWorkflowDefinition({ name: "Default", ir: linearIr("def") });
await store.setDefaultWorkflowId(def.id);
const res = await post("/api/tasks", { description: "no workflow", workflowId: null });
expect(res.status).toBe(201);
const created = res.body as { id: string };
const detail = await store.getTask(created.id);
expect(detail.enabledWorkflowSteps ?? []).toHaveLength(0);
expect(store.getTaskWorkflowSelection(created.id)).toBeUndefined();
});
it("fragment id → 4xx, no task created", async () => {
const frag = await store.createWorkflowDefinition({ name: "Frag", ir: fragmentIr(), kind: "fragment" });
const before = (await store.listTasks({ includeArchived: true })).length;
const res = await post("/api/tasks", { description: "frag", workflowId: frag.id });
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.status).toBeLessThan(500);
const after = (await store.listTasks({ includeArchived: true })).length;
expect(after).toBe(before);
});
it("unknown id → 4xx, no task created", async () => {
const before = (await store.listTasks({ includeArchived: true })).length;
const res = await post("/api/tasks", { description: "bad", workflowId: "WF-404" });
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.status).toBeLessThan(500);
const after = (await store.listTasks({ includeArchived: true })).length;
expect(after).toBe(before);
});
});

View File

@@ -874,6 +874,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
dependencies,
breakIntoSubtasks,
enabledWorkflowSteps,
workflowId,
modelPresetId,
modelProvider,
modelId,
@@ -962,6 +963,13 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
}
// Validate workflowId (U6/R3): undefined = inherit default, null = no
// workflow, string = that workflow. Unknown/fragment ids are rejected by
// the store below (mapped to 4xx in the catch handler).
if (workflowId !== undefined && workflowId !== null && typeof workflowId !== "string") {
throw badRequest("workflowId must be a string or null");
}
// Check for summarize flag in request
const summarize = req.body.summarize === true;
@@ -1245,6 +1253,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
dependencies,
breakIntoSubtasks,
enabledWorkflowSteps,
// U6/R3: forward only when the client set it (string | null). Leaving it
// absent preserves the project-default inheritance behavior.
...(workflowId !== undefined ? { workflowId: workflowId as string | null } : {}),
modelPresetId: validateOptionalModelField(modelPresetId, "modelPresetId"),
modelProvider: executorModel.provider ?? undefined,
modelId: executorModel.modelId ?? undefined,
@@ -1347,8 +1358,16 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (err instanceof ApiError) {
throw err;
}
const status = (err instanceof Error ? err.message : String(err)).includes("must be a string") || (err instanceof Error ? err.message : String(err)).includes("must be an array of strings") ? 400 : 500;
throw new ApiError(status, err instanceof Error ? err.message : String(err));
const message = err instanceof Error ? err.message : String(err);
// U6/R3: workflowId validation failures from the store (unknown id /
// fragment id) are client errors, not server faults.
const isClientError =
message.includes("must be a string")
|| message.includes("must be an array of strings")
|| /^Workflow '.*' not found$/.test(message)
|| /is a fragment and cannot be selected/.test(message);
const status = isClientError ? 400 : 500;
throw new ApiError(status, message);
}
});

View File

@@ -6218,8 +6218,12 @@
"titlePlaceholder": "Task title",
"useDropdown": "Use dropdown",
"usingPreset": "Using preset: {{name}}",
"workflowStepsDescription": "Select steps to run after task implementation completes",
"workflowStepsLabel": "Workflow Steps",
"workflowDefaultBadge": "(default)",
"workflowHelp": "The selected workflow's steps run automatically around this task's execution.",
"workflowLabel": "Workflow",
"workflowNone": "No workflow",
"workflowsCta": "Set up workflows in the editor",
"workflowsLoading": "Loading workflows…",
"workingBranchLabel": "Working branch"
},
"taskHandlers": {

View File

@@ -6220,8 +6220,12 @@ export default interface Resources {
"titlePlaceholder": "Task title",
"useDropdown": "Use dropdown",
"usingPreset": "Using preset: {{name}}",
"workflowStepsDescription": "Select steps to run after task implementation completes",
"workflowStepsLabel": "Workflow Steps",
"workflowDefaultBadge": "(default)",
"workflowHelp": "The selected workflow's steps run automatically around this task's execution.",
"workflowLabel": "Workflow",
"workflowNone": "No workflow",
"workflowsCta": "Set up workflows in the editor",
"workflowsLoading": "Loading workflows…",
"workingBranchLabel": "Working branch"
},
"taskHandlers": {