import "@xyflow/react/dist/style.css"; import "./WorkflowNodeEditor.css"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ReactFlow, ReactFlowProvider, Background, Controls, MiniMap, useNodesState, useEdgesState, useReactFlow, type Connection, type Node as FlowNode, type Edge as FlowEdge, } from "@xyflow/react"; import { useTranslation } from "react-i18next"; import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, Library, Sparkles } from "lucide-react"; import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { fetchWorkflows, createWorkflow, updateWorkflow, deleteWorkflow, compileWorkflow, exportWorkflow, importWorkflow, designWorkflow, ApiRequestError, migrateLegacyWorkflowSteps, fetchModels, fetchAgents, fetchDiscoveredSkills, fetchWorkflowStepTemplates, fetchPluginWorkflowStepTemplates, type ModelInfo, } from "../api"; import type { Agent } from "../api"; import type { DiscoveredSkill } from "../api"; import type { ToastType } from "../hooks/useToast"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import { useConfirm } from "../hooks/useConfirm"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; import { useAppSettings } from "../hooks/useAppSettings"; import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes"; import { WorkflowEditorCatalogContext } from "./nodes/WorkflowEditorCatalogContext"; import type { NodeSummaryCatalogs } from "./nodes/node-summary"; import { irToFlow, flowToIr, emptyWorkflowIr, emptyWorkflowLayout, copyIrWithFreshIds, insertFragment, fragmentSeamConflicts, columnsOf, fieldsOf, settingsOf, columnsToBandNodes, strictColumnForY, validateColumnsClient, unplacedNodeIds, isColumnBandNode, foreachChildFlowId, shortConditionLabel, edgeClassName, edgeConditionEditability, buildConnectionEdge, cascadeDelete, WF_EDGE_INTERACTION_WIDTH, FOREACH_GROUP_WIDTH, FOREACH_GROUP_HEIGHT, FOREACH_CHILD_X, FOREACH_CHILD_Y, } from "./workflow-flow-mapping"; import { autoLayout, applyAutoLayout } from "./workflow-auto-layout"; import { fetchTraits, fetchStepParsers, type TraitCatalogEntry } from "../api"; import { WorkflowColumnPanel } from "./WorkflowColumnPanel"; import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel"; import { WorkflowSettingsPanel } from "./WorkflowSettingsPanel"; import type { WorkflowFieldDefinition, WorkflowSettingDefinition } from "../api"; import { CustomModelDropdown } from "./CustomModelDropdown"; type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent"; /** Adapter descriptor served by GET /api/cli-agents (U15). */ interface CliAdapterDescriptorView { id: string; name: string; tier: "native" | "hybrid" | "generic"; } /** Static fallback so the picker renders before/without the API fetch. */ const CLI_AGENT_ADAPTER_FALLBACK: CliAdapterDescriptorView[] = [ { id: "claude-code", name: "Claude Code", tier: "native" }, { id: "codex", name: "Codex", tier: "hybrid" }, { id: "droid", name: "Droid", tier: "hybrid" }, { id: "pi", name: "Pi", tier: "hybrid" }, { id: "generic", name: "Generic CLI", tier: "generic" }, ]; // Mirror of @fusion/core's isBuiltinWorkflowId / BUILTIN_WORKFLOW_ID_PREFIX. // Inlined because the dashboard app build aliases "@fusion/core" to its // types-only entry (which doesn't re-export builtin-workflows), and importing // the function would pull the eager BUILTIN_WORKFLOWS construction into the // browser bundle for a one-line prefix check. const isBuiltinWorkflowId = (id: string): boolean => id.startsWith("builtin:"); function getModelDropdownValue(provider: string, modelId: string): string { return provider && modelId ? `${provider}/${modelId}` : ""; } 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) }; } /** Normalized serialization of the editor's authoring state for dirty tracking * (U4). Serializes nodes/edges through flowToIr (so mapping-layer defaults are * materialized identically on the loaded and live sides) plus the editor-owned * name/description and the resulting layout (auto-layout/drag position changes * count as dirty). Returns a stable JSON string for cheap equality. */ function serializeGraph( name: string, description: string, nodes: FlowNode[], edges: FlowEdge[], columns: WorkflowIrColumn[], fields: WorkflowFieldDefinition[], settings: WorkflowSettingDefinition[], ): string { const { ir, layout } = flowToIr( name, nodes, edges, columns.length ? columns : undefined, fields.length ? fields : undefined, settings.length ? settings : undefined, ); return JSON.stringify({ name, description, ir, layout }); } interface WorkflowNodeEditorProps { isOpen: boolean; onClose: () => void; addToast: (message: string, type?: ToastType) => void; projectId?: string; /** When "settings" the editor scrolls the WorkflowSettingsPanel into view on * mount (U6/U9: redirect stubs link here via a `?panel=settings` param read by * the editor's mount site). */ initialPanel?: "settings"; } let nodeSeq = 0; function newNodeId(): string { nodeSeq += 1; return `n-${Date.now().toString(36)}-${nodeSeq}`; } /** Built-in step parsers (KTD-12). Fallback list when the live catalog endpoint * (GET /api/step-parsers) is unreachable; the editor otherwise merges in any * registered plugin parsers fetched from the registry. */ const BUILTIN_STEP_PARSERS = ["step-headings", "json-steps"] as const; /** Step-review verdict outcomes (KTD-4), authored as `outcome:` edge * conditions and displayed as short labels. */ const STEP_REVIEW_VERDICTS = ["approve", "revise", "rethink", "unavailable"] as const; const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof MessageSquare; presetConfig?: Record }> = [ { kind: "prompt", label: "Prompt", icon: MessageSquare }, { kind: "prompt", label: "User input", icon: HelpCircle, presetConfig: { awaitInput: true } }, { kind: "script", label: "Script", icon: Terminal }, { kind: "gate", label: "Gate", icon: Shield }, { kind: "merge", label: "Merge boundary", icon: GitMerge }, { kind: "hold", label: "Hold", icon: PauseCircle, presetConfig: { release: "manual" } }, { kind: "split", label: "Split", icon: Split }, { kind: "join", label: "Join", icon: Merge, presetConfig: { mode: "all", onBranchFailure: "collect" } }, // Step-inversion (KTD-3/4/12/15). { kind: "foreach", label: "For-each step", icon: Repeat, presetConfig: { source: "task-steps" } }, { kind: "loop", label: "Loop", icon: Repeat, presetConfig: { maxIterations: 3, exitWhen: { type: "output-contains", value: "DONE" } } }, { kind: "step-review", label: "Step review", icon: ClipboardCheck, presetConfig: { type: "code" } }, { kind: "parse-steps", label: "Parse steps", icon: ListChecks, presetConfig: { artifact: "PROMPT.md", parser: "step-headings" } }, { kind: "code", label: "Code", icon: Code2, presetConfig: { source: "" } }, ]; /** Map a step template to a single pre-configured editor node (kind + config), * mirroring the U1 `stepInputToNode` converter's field mapping (mode → kind; * prompt/scriptName/toolMode/gateMode/model overrides → config). Inserting one * template thus produces the same node the steps→IR migration would. */ function stepTemplateToNode(tpl: WorkflowStepTemplate): { kind: WorkflowEditorNodeKind; label: string; config: Record; } { const config: Record = { name: tpl.name, // Always carry gateMode so a materialized node round-trips both modes. gateMode: tpl.gateMode ?? "advisory", }; if (tpl.description) config.description = tpl.description; if (tpl.mode === "script") { if (tpl.scriptName) config.scriptName = tpl.scriptName; return { kind: "script", label: tpl.name, config }; } // prompt mode (default) config.prompt = tpl.prompt ?? ""; config.toolMode = tpl.toolMode === "coding" ? "coding" : "readonly"; // Model overrides only round-trip when BOTH are present (compiler requirement). if (tpl.modelProvider && tpl.modelId) { config.modelProvider = tpl.modelProvider; config.modelId = tpl.modelId; } return { kind: "prompt", label: tpl.name, config }; } // Node kinds a user authors from the palette. Structural/derived nodes // (start/end and column bands — which map to data.kind "start") are excluded, so // a fresh start→end graph counts as trivial. Used by the palette-hint (R9). const USER_NODE_KINDS: ReadonlySet = new Set([ "prompt", "script", "gate", "code", "hold", "split", "join", "foreach", "loop", "step-review", "parse-steps", "merge", ]); /** A pickable creation template: "Blank" (id null) or a copyable source * workflow (built-in or user kind="workflow"). U4/R7. */ interface WorkflowCreateTemplate { /** null = blank; otherwise the source definition's id. */ id: string | null; name: string; description: string; /** Node count of the source IR (0 for blank). */ nodeCount: number; /** Source definition for seeding via copyIrWithFreshIds (absent for blank). */ source?: WorkflowDefinition; /** True for built-in sources (grouped separately). */ builtin: boolean; } /** Local create-workflow dialog (KTD-7). Built on the shared `.modal` primitives * (precedent: NewTaskModal). Owns its own template/name/description/error state; * the parent supplies the candidate `workflows` (fragments filtered out here) * and an async `onCreate` that performs the createWorkflow call and throws on * failure so the dialog can surface server rejections inline without losing the * typed input. Escape/overlay close (no dirty state of its own). * * U4/R7: a template step precedes the name/description fields — a * radiogroup-semantics option list (Blank default-selected + built-ins + user * workflows) navigable by ArrowUp/Down; selecting a template prefills the name * (" copy") while untouched and inherits the source description. */ function CreateWorkflowDialog({ workflows, onCreate, onDesign, onClose, }: { workflows: WorkflowDefinition[]; onCreate: (name: string, description: string, template: WorkflowCreateTemplate) => Promise; /** U10/R11: design a brand-new workflow from a prompt. Resolves on success * (the parent seeds + activates the workflow and closes the dialog); throws on * failure so the dialog surfaces the server message inline without closing. * `signal` aborts the in-flight design request. */ onDesign: (prompt: string, name: string, signal: AbortSignal) => Promise; onClose: () => void; }) { const { t } = useTranslation("app"); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); // U10/R11: AI-design disclosure state. `aiOpen` reveals the prompt textarea; // `aiPrompt` holds the request; `aiBusy` flags the in-flight design call (the // submit disables + a spinner + Cancel show); `aiError` is the inline failure. const [aiOpen, setAiOpen] = useState(false); const [aiPrompt, setAiPrompt] = useState(""); const [aiBusy, setAiBusy] = useState(false); const [aiError, setAiError] = useState(null); const aiAbortRef = useRef(null); // Tracks whether the user has edited the name; once true, selecting a template // no longer overwrites it (R7: prefill only when untouched). const [nameTouched, setNameTouched] = useState(false); const nameRef = useRef(null); const optionRefs = useRef>([]); // Build the option list: Blank first (default), then built-in workflows, then // the user's own kind="workflow" definitions. Fragments are excluded entirely. const templates = useMemo(() => { const blank: WorkflowCreateTemplate = { id: null, name: t("workflows.templateBlank", "Blank"), description: t("workflows.templateBlankDescription", "Start from an empty start → end graph."), nodeCount: 0, builtin: false, }; const usable = workflows.filter((w) => w.kind !== "fragment"); const toTemplate = (w: WorkflowDefinition): WorkflowCreateTemplate => ({ id: w.id, name: w.name, description: w.description ?? "", nodeCount: w.ir.nodes.length, source: w, builtin: isBuiltinWorkflowId(w.id), }); const builtins = usable.filter((w) => isBuiltinWorkflowId(w.id)).map(toTemplate); const yours = usable.filter((w) => !isBuiltinWorkflowId(w.id)).map(toTemplate); return [blank, ...builtins, ...yours]; }, [workflows, t]); const [selectedIndex, setSelectedIndex] = useState(0); const selected = templates[selectedIndex] ?? templates[0]; useEffect(() => { nameRef.current?.focus(); }, []); // Apply a template selection: move the radio focus state and (R7) prefill the // name (" copy") + description from the source, but only while the user // has not edited the name. const selectTemplate = useCallback( (index: number) => { const tmpl = templates[index]; if (!tmpl) return; setSelectedIndex(index); if (!nameTouched) { if (tmpl.id === null) { setName(""); setDescription(""); } else { setName(t("workflows.templateCopyName", "{{name}} copy", { name: tmpl.name })); setDescription(tmpl.description); } } if (error) setError(null); }, [templates, nameTouched, error, t], ); // ArrowUp/Down move the radio selection; Enter confirms and shifts focus to // the name input. Other keys (incl. Escape) bubble to the dialog handler. const handleOptionKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "ArrowDown" || e.key === "ArrowRight") { e.preventDefault(); const next = Math.min(selectedIndex + 1, templates.length - 1); selectTemplate(next); optionRefs.current[next]?.focus(); } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") { e.preventDefault(); const prev = Math.max(selectedIndex - 1, 0); selectTemplate(prev); optionRefs.current[prev]?.focus(); } else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); selectTemplate(selectedIndex); nameRef.current?.focus(); } }, [selectedIndex, templates.length, selectTemplate], ); const overlayProps = useOverlayDismiss(onClose); const handleSubmit = useCallback( async (e: React.FormEvent) => { e.preventDefault(); const trimmed = name.trim(); if (!trimmed) { setError(t("workflows.createNameRequired", "Enter a workflow name")); return; } setSubmitting(true); setError(null); try { await onCreate(trimmed, description.trim(), selected); // Success path closes the dialog from the parent. } catch (err) { setError(getErrorMessage(err) || t("workflows.createFailed", "Failed to create workflow")); setSubmitting(false); } }, [name, description, selected, onCreate, t], ); // U10/R11: submit the AI design request. On success the parent seeds the // workflow and closes the dialog; on failure the server message renders inline // (role="alert") and the dialog stays open. The fetch is cancelable via the // Cancel button (AbortController); an abort re-enables the controls silently. const handleAiSubmit = useCallback(async () => { const trimmed = aiPrompt.trim(); if (!trimmed) { setAiError(t("workflows.aiPromptRequired", "Describe the workflow you want")); return; } const controller = new AbortController(); aiAbortRef.current = controller; setAiBusy(true); setAiError(null); try { await onDesign(trimmed, name.trim(), controller.signal); // Success closes the dialog from the parent. } catch (err) { if (controller.signal.aborted) { // User-initiated cancel: re-enable silently (no error message). return; } setAiError(getErrorMessage(err) || t("workflows.aiFailed", "Failed to design workflow")); } finally { if (aiAbortRef.current === controller) aiAbortRef.current = null; setAiBusy(false); } }, [aiPrompt, name, onDesign, t]); const handleAiCancel = useCallback(() => { aiAbortRef.current?.abort(); setAiBusy(false); }, []); // Section boundaries for group headers (built-ins / your workflows). Blank is // always index 0; built-ins follow, then user workflows. const firstBuiltinIndex = templates.findIndex((tmpl) => tmpl.id !== null && tmpl.builtin); const firstYoursIndex = templates.findIndex((tmpl) => tmpl.id !== null && !tmpl.builtin); return (
e.stopPropagation()} onKeyDown={(e) => { if (e.key === "Escape") { e.stopPropagation(); onClose(); } }} >

{t("workflows.createTitle", "New workflow")}

{/* U10/R11: AI-design disclosure. Toggling reveals a prompt textarea + "Design with AI" submit; submitting designs a brand-new workflow from the result (the parent seeds + activates it). In-flight: the submit disables + spins, aria-busy is set on the section, and a Cancel aborts the fetch. Failure renders inline (role="alert"). */}
{aiOpen && (