diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 662397ecc1..fbad170578 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -5182,6 +5182,34 @@ export function migrateLegacyWorkflowSteps(projectId?: string): Promise { + return api(withProjectId("/workflows/design", projectId), { + method: "POST", + body: JSON.stringify(input), + signal, + }); +} + /** Read the workflow currently selected for a task. */ export function fetchTaskWorkflow(taskId: string, projectId?: string): Promise<{ workflowId: string | null }> { return api<{ workflowId: string | null }>( diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index a821614f1e..2d100a4964 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -1001,3 +1001,83 @@ text-transform: uppercase; color: var(--text-tertiary); } + +/* ── U10/R11: Design-with-AI affordances ─────────────────────────────────── */ + +/* Create-dialog disclosure (above the template picker). */ +.wf-ai-create { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-sm); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-secondary); +} + +.wf-ai-toggle { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + align-self: flex-start; + padding: var(--space-2xs) var(--space-xs); + background: transparent; + border: none; + border-radius: var(--radius-sm); + color: var(--accent); + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; +} + +.wf-ai-toggle:hover { + background: var(--bg-hover); +} + +.wf-ai-create-body { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.wf-ai-prompt { + width: 100%; + resize: vertical; + padding: var(--space-xs) var(--space-sm); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text); + font-size: 0.85rem; +} + +.wf-ai-prompt:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +.wf-ai-actions { + display: flex; + gap: var(--space-xs); +} + +/* Toolbar popover panel anchored under the "Design with AI" button. */ +.wf-ai-edit-wrap { + position: relative; +} + +.wf-ai-panel { + position: absolute; + top: calc(100% + var(--space-2xs)); + right: 0; + z-index: 20; + width: 320px; + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-sm); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg); + box-shadow: var(--shadow-md, 0 4px 16px rgba(0, 0, 0, 0.2)); +} diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 115d8cf4d7..5273a104d3 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -14,7 +14,7 @@ import { 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 } from "lucide-react"; +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 { @@ -25,6 +25,7 @@ import { compileWorkflow, exportWorkflow, importWorkflow, + designWorkflow, ApiRequestError, migrateLegacyWorkflowSteps, fetchModels, @@ -236,10 +237,16 @@ interface WorkflowCreateTemplate { 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"); @@ -247,6 +254,14 @@ function CreateWorkflowDialog({ 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); @@ -352,6 +367,40 @@ function CreateWorkflowDialog({ [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); @@ -386,6 +435,72 @@ function CreateWorkflowDialog({
+ {/* 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 && ( +
+