import { useState, useCallback, useEffect } from "react"; import { Plus, Trash2, ChevronUp, ChevronDown, Pencil, GripVertical } from "lucide-react"; import type { AutomationStep, AutomationStepType } from "@fusion/core"; import { StepTypeBadge } from "./StepTypeBadge"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { fetchModels } from "../api"; import type { ModelInfo } from "../api"; interface ScheduleStepsEditorProps { steps: AutomationStep[]; onChange: (steps: AutomationStep[]) => void; /** Called when editing state changes. Useful for parent form validation. */ onEditingChange?: (isEditing: boolean) => void; } function generateStepId(): string { // crypto.randomUUID() may be unavailable in non-secure contexts (HTTP), // older browsers, or some test environments. Fall back to a // cryptographically-acceptable alternative when needed. if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } // Deterministic fallback: timestamp + random hex return `step-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; } function createEmptyStep(type: AutomationStepType): AutomationStep { if (type === "command") { return { id: generateStepId(), type, name: "New Command Step", command: "", continueOnFailure: false, }; } if (type === "ai-prompt") { return { id: generateStepId(), type, name: "New AI Prompt Step", prompt: "", continueOnFailure: false, }; } // create-task return { id: generateStepId(), type, name: "New Create Task Step", taskDescription: "", taskColumn: "triage", continueOnFailure: false, }; } interface StepEditorProps { step: AutomationStep; onSave: (step: AutomationStep) => void; onCancel: () => void; } function StepEditor({ step, onSave, onCancel }: StepEditorProps) { const [name, setName] = useState(step.name); const [type, setType] = useState(step.type); const [command, setCommand] = useState(step.command ?? ""); const [prompt, setPrompt] = useState(step.prompt ?? ""); const [modelProvider, setModelProvider] = useState(step.modelProvider ?? ""); const [modelId, setModelId] = useState(step.modelId ?? ""); const [taskTitle, setTaskTitle] = useState(step.taskTitle ?? ""); const [taskDescription, setTaskDescription] = useState(step.taskDescription ?? ""); const [taskColumn, setTaskColumn] = useState(step.taskColumn ?? "triage"); const [timeoutMs, setTimeoutMs] = useState(step.timeoutMs); const [continueOnFailure, setContinueOnFailure] = useState(step.continueOnFailure ?? false); const [errors, setErrors] = useState>({}); const [models, setModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); const [modelsError, setModelsError] = useState(null); // Fetch models on mount useEffect(() => { let cancelled = false; setModelsLoading(true); setModelsError(null); fetchModels() .then((response) => { if (!cancelled) { setModels(response.models); } }) .catch((err: unknown) => { if (!cancelled) { setModelsError(err instanceof Error ? err.message : "Failed to load models"); } }) .finally(() => { if (!cancelled) { setModelsLoading(false); } }); return () => { cancelled = true; }; }, []); const validate = useCallback((): boolean => { const e: Record = {}; if (!name.trim()) e.name = "Step name is required"; if (type === "command" && !command.trim()) e.command = "Command is required"; if (type === "ai-prompt" && !prompt.trim()) e.prompt = "Prompt is required"; if (type === "create-task" && !taskDescription.trim()) e.taskDescription = "Task description is required"; // Model pairing validation for ai-prompt and create-task if ((type === "ai-prompt" || type === "create-task") && (modelProvider || modelId)) { if (!modelProvider || !modelId) { e.modelProvider = "Both model provider and model ID must be set together"; } } if (timeoutMs !== undefined && timeoutMs < 1000) { e.timeoutMs = "Timeout must be at least 1 second (1000ms)"; } setErrors(e); return Object.keys(e).length === 0; }, [name, type, command, prompt, taskDescription, modelProvider, modelId, timeoutMs]); // Compute combined model value from separate fields const modelValue = (modelProvider && modelId) ? `${modelProvider}/${modelId}` : ""; // Handle model selection from the dropdown const handleModelChange = useCallback((value: string) => { if (!value) { setModelProvider(""); setModelId(""); } else { const slashIdx = value.indexOf("/"); if (slashIdx !== -1) { setModelProvider(value.slice(0, slashIdx)); setModelId(value.slice(slashIdx + 1)); } } }, []); const handleSave = useCallback(() => { if (!validate()) return; // Clear fields that don't apply to this step type const baseStep = { ...step, name: name.trim(), type, command: type === "command" ? command.trim() : undefined, prompt: type === "ai-prompt" ? prompt.trim() : undefined, taskTitle: type === "create-task" && taskTitle.trim() ? taskTitle.trim() : undefined, taskDescription: type === "create-task" && taskDescription.trim() ? taskDescription.trim() : undefined, taskColumn: type === "create-task" ? taskColumn : undefined, modelProvider: (type === "ai-prompt" || type === "create-task") && modelProvider.trim() ? modelProvider.trim() : undefined, modelId: (type === "ai-prompt" || type === "create-task") && modelId.trim() ? modelId.trim() : undefined, timeoutMs: timeoutMs || undefined, continueOnFailure, }; // Clear ai-prompt and create-task specific fields when switching to command if (type !== "ai-prompt") { delete baseStep.prompt; } if (type !== "create-task") { delete baseStep.taskTitle; delete baseStep.taskDescription; delete baseStep.taskColumn; } onSave(baseStep as AutomationStep); }, [validate, onSave, step, name, type, command, prompt, taskTitle, taskDescription, taskColumn, modelProvider, modelId, timeoutMs, continueOnFailure]); return (
setName(e.target.value)} aria-invalid={!!errors.name} /> {errors.name && {errors.name}}
{type === "command" && (