import "./WorkflowStepManager.css"; import { useState, useEffect, useCallback, useRef } from "react"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import type { WorkflowStep, WorkflowStepInput, WorkflowStepMode, WorkflowStepPhase } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { fetchWorkflowSteps, createWorkflowStep, updateWorkflowStep, deleteWorkflowStep, refineWorkflowStepPrompt, fetchWorkflowStepTemplates, createWorkflowStepFromTemplate, fetchScripts, fetchModels, type WorkflowStepTemplate, type ModelInfo, } from "../api"; import type { ToastType } from "../hooks/useToast"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; import { X, Plus, Pencil, Trash2, Sparkles, Check, Loader2, FileText, CheckCircle, Shield, Zap, Eye, Globe, LayoutGrid, BookOpen, Terminal, MessageSquare, } from "lucide-react"; import { CustomModelDropdown } from "./CustomModelDropdown"; interface WorkflowStepManagerProps { isOpen: boolean; onClose: () => void; addToast: (message: string, type?: ToastType) => void; projectId?: string; } interface StepFormData { name: string; description: string; mode: WorkflowStepMode; phase: WorkflowStepPhase; prompt: string; scriptName: string; enabled: boolean; defaultOn: boolean; modelProvider: string; modelId: string; } type TabId = "my-steps" | "templates"; const EMPTY_FORM: StepFormData = { name: "", description: "", mode: "prompt", phase: "pre-merge" as WorkflowStepPhase, prompt: "", scriptName: "", enabled: true, defaultOn: false, modelProvider: "", modelId: "", }; /** Build the combined "provider/modelId" value for CustomModelDropdown */ function getModelDropdownValue(provider: string, modelId: string): string { return provider && modelId ? `${provider}/${modelId}` : ""; } /** Parse "provider/modelId" dropdown value back into separate fields */ function parseModelDropdownValue(value: string): { provider: string; modelId: string } { if (!value) return { provider: "", modelId: "" }; const slashIndex = value.indexOf("/"); if (slashIndex === -1) return { provider: "", modelId: "" }; return { provider: value.slice(0, slashIndex), modelId: value.slice(slashIndex + 1) }; } /** Map template icon names to Lucide components */ function getTemplateIcon(iconName: string | undefined) { switch (iconName) { case "file-text": return FileText; case "check-circle": return CheckCircle; case "shield": return Shield; case "zap": return Zap; case "eye": return Eye; case "globe": return Globe; case "layout-grid": return LayoutGrid; default: return CheckCircle; } } /** Get category badge class name */ function getCategoryClassName(category: string): string { switch (category.toLowerCase()) { case "quality": return "wfm-badge-category wfm-badge-category-quality"; case "security": return "wfm-badge-category wfm-badge-category-security"; default: return "wfm-badge-category"; } } export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: WorkflowStepManagerProps) { const [steps, setSteps] = useState([]); const [templates, setTemplates] = useState([]); const [loading, setLoading] = useState(true); const [templatesLoading, setTemplatesLoading] = useState(true); const [activeTab, setActiveTab] = useState("my-steps"); const [editingId, setEditingId] = useState(null); const [isCreating, setIsCreating] = useState(false); const [form, setForm] = useState(EMPTY_FORM); const [saving, setSaving] = useState(false); const [refining, setRefining] = useState(false); const [deleteConfirmId, setDeleteConfirmId] = useState(null); const [addingTemplateId, setAddingTemplateId] = useState(null); const [availableScripts, setAvailableScripts] = useState>({}); const [availableModels, setAvailableModels] = useState([]); const modalRef = useRef(null); useModalResizePersist(modalRef, isOpen, "fusion:workflow-steps-modal-size"); const loadSteps = useCallback(async () => { try { setLoading(true); const data = await fetchWorkflowSteps(projectId); setSteps(data); } catch (err) { addToast(getErrorMessage(err) || "Failed to load workflow steps", "error"); } finally { setLoading(false); } }, [addToast, projectId]); const loadScripts = useCallback(async () => { try { const scripts = await fetchScripts(projectId); setAvailableScripts(scripts || {}); } catch { // Silently ignore — scripts are optional } }, [projectId]); const loadModels = useCallback(async () => { try { const response = await fetchModels(); setAvailableModels(response.models || []); } catch { // Silently ignore — models are optional, dropdown will be empty } }, []); const loadTemplates = useCallback(async () => { try { setTemplatesLoading(true); const response = await fetchWorkflowStepTemplates(); setTemplates(response.templates); } catch (err) { addToast(getErrorMessage(err) || "Failed to load templates", "error"); } finally { setTemplatesLoading(false); } }, [addToast]); useEffect(() => { if (isOpen) { loadSteps(); loadTemplates(); loadScripts(); loadModels(); } }, [isOpen, loadSteps, loadTemplates, loadScripts, loadModels]); const handleCreate = useCallback(() => { setIsCreating(true); setEditingId(null); setForm(EMPTY_FORM); }, []); const handleEdit = useCallback((step: WorkflowStep) => { setEditingId(step.id); setIsCreating(false); setForm({ name: step.name, description: step.description, mode: step.mode || "prompt", phase: step.phase || "pre-merge", prompt: step.prompt, scriptName: step.scriptName || "", enabled: step.enabled, defaultOn: step.defaultOn || false, modelProvider: step.modelProvider || "", modelId: step.modelId || "", }); }, []); const handleCancel = useCallback(() => { setEditingId(null); setIsCreating(false); setForm(EMPTY_FORM); }, []); const handleSave = useCallback(async () => { if (!form.name.trim() || !form.description.trim()) { addToast("Name and description are required", "error"); return; } setSaving(true); try { // Build model override: only include when both fields are set (prompt mode) const modelFields = form.mode === "prompt" && form.modelProvider && form.modelId ? { modelProvider: form.modelProvider, modelId: form.modelId } : form.mode === "prompt" ? { modelProvider: undefined, modelId: undefined } : {}; if (isCreating) { const input: WorkflowStepInput = { name: form.name.trim(), description: form.description.trim(), mode: form.mode, phase: form.phase, prompt: form.mode === "prompt" ? (form.prompt.trim() || undefined) : undefined, scriptName: form.mode === "script" ? form.scriptName.trim() : undefined, enabled: form.enabled, defaultOn: form.defaultOn || undefined, ...modelFields, }; await createWorkflowStep(input, projectId); addToast("Workflow step created", "success"); } else if (editingId) { await updateWorkflowStep(editingId, { name: form.name.trim(), description: form.description.trim(), mode: form.mode, phase: form.phase, prompt: form.mode === "prompt" ? form.prompt : "", scriptName: form.mode === "script" ? form.scriptName.trim() : undefined, enabled: form.enabled, defaultOn: form.defaultOn, ...modelFields, }, projectId); addToast("Workflow step updated", "success"); } setIsCreating(false); setEditingId(null); setForm(EMPTY_FORM); await loadSteps(); } catch (err) { addToast(getErrorMessage(err) || "Failed to save workflow step", "error"); } finally { setSaving(false); } }, [form, isCreating, editingId, addToast, loadSteps]); const handleDelete = useCallback(async (id: string) => { try { await deleteWorkflowStep(id, projectId); addToast("Workflow step deleted", "success"); setDeleteConfirmId(null); if (editingId === id) { setEditingId(null); setForm(EMPTY_FORM); } await loadSteps(); } catch (err) { addToast(getErrorMessage(err) || "Failed to delete workflow step", "error"); } }, [editingId, addToast, loadSteps]); const handleRefine = useCallback(async () => { if (!editingId && !isCreating) return; // Refine only works for prompt mode if (form.mode !== "prompt") return; // For new steps being created, we need to save first then refine if (isCreating) { if (!form.name.trim() || !form.description.trim()) { addToast("Name and description are required before refining", "error"); return; } setSaving(true); try { // Build model override for the intermediate create-then-refine flow const modelFields = form.modelProvider && form.modelId ? { modelProvider: form.modelProvider, modelId: form.modelId } : {}; const input: WorkflowStepInput = { name: form.name.trim(), description: form.description.trim(), mode: "prompt", prompt: form.prompt.trim() || undefined, enabled: form.enabled, defaultOn: form.defaultOn || undefined, ...modelFields, }; const created = await createWorkflowStep(input, projectId); setIsCreating(false); setEditingId(created.id); // Now refine setRefining(true); const result = await refineWorkflowStepPrompt(created.id, projectId); setForm((prev) => ({ ...prev, prompt: result.prompt })); addToast("Prompt refined with AI", "success"); await loadSteps(); } catch (err) { addToast(getErrorMessage(err) || "Failed to refine prompt", "error"); } finally { setSaving(false); setRefining(false); } return; } if (!editingId) return; setRefining(true); try { const result = await refineWorkflowStepPrompt(editingId, projectId); setForm((prev) => ({ ...prev, prompt: result.prompt })); addToast("Prompt refined with AI", "success"); await loadSteps(); } catch (err) { addToast(getErrorMessage(err) || "Failed to refine prompt", "error"); } finally { setRefining(false); } }, [editingId, isCreating, form, addToast, loadSteps]); const handleAddTemplate = useCallback(async (template: WorkflowStepTemplate) => { setAddingTemplateId(template.id); try { await createWorkflowStepFromTemplate(template.id, projectId); addToast(`Added ${template.name} workflow step`, "success"); await loadSteps(); // Switch to "My Workflow Steps" tab to show the newly added step setActiveTab("my-steps"); } catch (err) { const msg = getErrorMessage(err); if (msg?.includes("already exists")) { addToast(`A workflow step named '${template.name}' already exists`, "error"); } else { addToast(msg || "Failed to add workflow step from template", "error"); } } finally { setAddingTemplateId(null); } }, [addToast, loadSteps]); // useOverlayDismiss MUST be called before any early return — otherwise the // hook count differs between isOpen=false (returns early after the existing // useState/useEffect/useCallback hooks) and isOpen=true (also calls // useOverlayDismiss), which trips React error #310 the moment the modal // is opened. That bug currently breaks the workflow steps panel from // loading at all. const overlayDismissProps = useOverlayDismiss(onClose); if (!isOpen) return null; const isEditing = isCreating || editingId !== null; return (
{/* Header */}

Workflow Steps

{loading ? (
Loading...
) : ( <> {/* Tab Navigation */} {!isEditing && (
)} {/* My Workflow Steps Tab */} {activeTab === "my-steps" && !isEditing && ( <> {steps.length === 0 && (
No workflow steps defined. Create one to get started, or add one from the Templates tab.
)} {steps.length > 0 && (
{steps.map((step) => (
{step.name} {step.enabled ? "Enabled" : "Disabled"} {(step.mode || "prompt") === "script" ? "Script" : "AI Prompt"} {(step.phase || "pre-merge") === "post-merge" ? "Post-merge" : "Pre-merge"} {step.defaultOn && ( Default on )}
{step.description}
{deleteConfirmId === step.id ? (
) : ( )}
))}
)} )} {/* Templates Tab */} {activeTab === "templates" && !isEditing && ( <> {templatesLoading ? (
Loading templates...
) : templates.length === 0 ? (
No templates available.
) : (
{templates.map((template) => { const IconComponent = getTemplateIcon(template.icon); const categoryClassName = getCategoryClassName(template.category); const isAdding = addingTemplateId === template.id; return (
{/* Icon */}
{/* Content */}
{template.name} {template.category}
{template.description}
); })}
)} )} {/* Edit / Create form */} {isEditing && (

{isCreating ? "New Workflow Step" : "Edit Workflow Step"}

{/* Name */}
setForm((prev) => ({ ...prev, name: e.target.value }))} placeholder="e.g. Documentation Review" data-testid="workflow-step-name" />
{/* Description */}