feat(dashboard): design-with-AI affordances in the workflow editor
This commit is contained in:
@@ -5182,6 +5182,34 @@ export function migrateLegacyWorkflowSteps(projectId?: string): Promise<MigrateL
|
||||
});
|
||||
}
|
||||
|
||||
/** Result of POST /api/workflows/design (U10/R11). The server validates the
|
||||
* AI-produced IR (parseWorkflowIr), triages compilability (`interpreterOnly`),
|
||||
* and strips trust-escalating flags (`strippedApprovalFlags`). Persists nothing
|
||||
* — the client decides what to do with the returned graph. */
|
||||
export interface DesignWorkflowResult {
|
||||
ir: import("@fusion/core").WorkflowIr;
|
||||
layout: import("@fusion/core").WorkflowDefinition["layout"];
|
||||
interpreterOnly: boolean;
|
||||
strippedApprovalFlags: boolean;
|
||||
}
|
||||
|
||||
/** Design a workflow from a natural-language prompt (U10/R11). When `workflowId`
|
||||
* is supplied the route reads that workflow's persisted IR server-side and folds
|
||||
* it into the prompt as the base graph (the client never posts IR). An optional
|
||||
* AbortSignal cancels the in-flight request. Validation failures reject with an
|
||||
* ApiError carrying the server message; 429 on rate limit. */
|
||||
export function designWorkflow(
|
||||
input: { prompt: string; workflowId?: string },
|
||||
projectId?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DesignWorkflowResult> {
|
||||
return api<DesignWorkflowResult>(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 }>(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
/** 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<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("app");
|
||||
@@ -247,6 +254,14 @@ function CreateWorkflowDialog({
|
||||
const [description, setDescription] = useState("");
|
||||
const [error, setError] = useState<string | null>(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<string | null>(null);
|
||||
const aiAbortRef = useRef<AbortController | null>(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({
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body">
|
||||
{/* 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"). */}
|
||||
<div className="wf-ai-create" aria-busy={aiBusy} data-testid="wf-ai-create">
|
||||
<button
|
||||
type="button"
|
||||
className="wf-ai-toggle"
|
||||
data-testid="wf-ai-toggle"
|
||||
aria-expanded={aiOpen}
|
||||
onClick={() => {
|
||||
setAiOpen((o) => !o);
|
||||
setAiError(null);
|
||||
}}
|
||||
>
|
||||
<Sparkles size={13} />{" "}
|
||||
{t("workflows.aiToggle", "Describe it instead")}
|
||||
</button>
|
||||
{aiOpen && (
|
||||
<div className="wf-ai-create-body">
|
||||
<textarea
|
||||
className="wf-ai-prompt"
|
||||
data-testid="wf-ai-prompt"
|
||||
rows={3}
|
||||
value={aiPrompt}
|
||||
disabled={aiBusy}
|
||||
placeholder={t(
|
||||
"workflows.aiPromptPlaceholder",
|
||||
"e.g. Run lint and tests before merge, then post a changelog comment after merge",
|
||||
)}
|
||||
onChange={(e) => {
|
||||
setAiPrompt(e.target.value);
|
||||
if (aiError) setAiError(null);
|
||||
}}
|
||||
/>
|
||||
{aiError && (
|
||||
<p className="wf-create-error" role="alert" data-testid="wf-ai-error">
|
||||
{aiError}
|
||||
</p>
|
||||
)}
|
||||
<div className="wf-ai-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary wf-ai-submit"
|
||||
data-testid="wf-ai-submit"
|
||||
disabled={aiBusy}
|
||||
onClick={() => void handleAiSubmit()}
|
||||
>
|
||||
{aiBusy ? <Loader2 size={13} className="wf-spin" /> : <Sparkles size={13} />}{" "}
|
||||
{t("workflows.aiSubmit", "Design with AI")}
|
||||
</button>
|
||||
{aiBusy && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn wf-ai-cancel"
|
||||
data-testid="wf-ai-cancel"
|
||||
onClick={handleAiCancel}
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="wf-field">
|
||||
<span id="wf-template-label">{t("workflows.templatePickerLabel", "Start from")}</span>
|
||||
<div
|
||||
@@ -576,6 +691,21 @@ function InnerEditor({
|
||||
const [importing, setImporting] = useState(false);
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// U10/R11: toolbar "Design with AI" panel state. `aiPanelOpen` toggles the
|
||||
// popover; `aiEditPrompt` holds the request; `aiEditBusy` flags the in-flight
|
||||
// call (submit disables + spins, Cancel shows); `aiEditError` is the inline
|
||||
// failure. The proposed replacement applies only through the dirty-guard
|
||||
// confirm — and always confirms (destructive whole-graph replace).
|
||||
const [aiPanelOpen, setAiPanelOpen] = useState(false);
|
||||
const [aiEditPrompt, setAiEditPrompt] = useState("");
|
||||
const [aiEditBusy, setAiEditBusy] = useState(false);
|
||||
const [aiEditError, setAiEditError] = useState<string | null>(null);
|
||||
const aiEditAbortRef = useRef<AbortController | null>(null);
|
||||
// U10/R11: when a create-from-AI result is interpreter-only, the new workflow
|
||||
// becomes active and its load effect resets the banner — so we stash the flag
|
||||
// here and the load effect re-raises it once for the workflow it activates.
|
||||
const pendingInterpreterOnlyRef = useRef(false);
|
||||
|
||||
const activeWorkflow = useMemo(() => workflows.find((w) => w.id === activeId), [workflows, activeId]);
|
||||
const isBuiltin = !!activeWorkflow && isBuiltinWorkflowId(activeWorkflow.id);
|
||||
|
||||
@@ -867,7 +997,14 @@ function InnerEditor({
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
setValidationError(null);
|
||||
setInterpreterOnly(false);
|
||||
// Honor a pending AI interpreter-only flag exactly once for the workflow it
|
||||
// just activated; otherwise the banner clears on load (U10/R11).
|
||||
if (pendingInterpreterOnlyRef.current) {
|
||||
pendingInterpreterOnlyRef.current = false;
|
||||
setInterpreterOnly(true);
|
||||
} else {
|
||||
setInterpreterOnly(false);
|
||||
}
|
||||
}, [activeWorkflow, setNodes, setEdges]);
|
||||
|
||||
// Server-reported node error (e.g. seam-in-branch) attributed to a node id.
|
||||
@@ -1026,6 +1163,93 @@ function InnerEditor({
|
||||
setNodes((ns) => applyAutoLayout(ns, autoLayout(ns, edges, columns)));
|
||||
}, [setNodes, edges, columns]);
|
||||
|
||||
// U10/R11: toolbar "Design with AI" submit. Designs against the ACTIVE workflow
|
||||
// (passing its id; the server reads the persisted IR — the client never posts
|
||||
// IR). On success the returned graph REPLACES the canvas, but only after a
|
||||
// confirm — and we ALWAYS confirm (even when clean) because this is a
|
||||
// destructive whole-graph replace. On confirm we map the returned {ir, layout}
|
||||
// through irToFlow on a definition-shaped object (mirroring the load effect's
|
||||
// mapping); the result is intentionally left UNSAVED so the user explicitly
|
||||
// saves (the dirty snapshot is the active workflow's, so the replaced graph
|
||||
// reads dirty). On failure the canvas is untouched and the panel shows the
|
||||
// server message inline. The fetch is cancelable via the panel's Cancel button.
|
||||
const handleAiEditSubmit = useCallback(async () => {
|
||||
if (!activeWorkflow || isBuiltin) return;
|
||||
const trimmed = aiEditPrompt.trim();
|
||||
if (!trimmed) {
|
||||
setAiEditError(t("workflows.aiPromptRequired", "Describe the workflow you want"));
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
aiEditAbortRef.current = controller;
|
||||
setAiEditBusy(true);
|
||||
setAiEditError(null);
|
||||
try {
|
||||
const result = await designWorkflow(
|
||||
{ prompt: trimmed, workflowId: activeWorkflow.id },
|
||||
projectId,
|
||||
controller.signal,
|
||||
);
|
||||
// Always confirm before the destructive replace.
|
||||
const ok = await confirm({
|
||||
title: t("workflows.aiReplaceTitle", "Replace graph?"),
|
||||
message: t(
|
||||
"workflows.aiReplaceConfirm",
|
||||
"Replace the current graph with the AI design? Unsaved changes will be lost.",
|
||||
),
|
||||
confirmLabel: t("workflows.aiReplaceConfirmLabel", "Replace"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return; // Cancel keeps the current canvas untouched.
|
||||
// Map the returned IR through irToFlow on a definition-shaped object,
|
||||
// mirroring the active-workflow load effect's mapping.
|
||||
const flow = irToFlow({
|
||||
...activeWorkflow,
|
||||
ir: result.ir,
|
||||
layout: result.layout,
|
||||
});
|
||||
setNodes(flow.nodes);
|
||||
setEdges(flow.edges);
|
||||
setColumns(columnsOf({ ...activeWorkflow, ir: result.ir }));
|
||||
setFields(fieldsOf({ ...activeWorkflow, ir: result.ir }));
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
setValidationError(null);
|
||||
// Leave the loaded snapshot pointing at the (still-persisted) base so the
|
||||
// replaced graph reads dirty — the user must explicitly Save.
|
||||
setInterpreterOnly(result.interpreterOnly);
|
||||
if (result.strippedApprovalFlags) {
|
||||
addToast(
|
||||
t("workflows.importStripped", "Auto-approval flags were removed from imported nodes"),
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
setAiPanelOpen(false);
|
||||
setAiEditPrompt("");
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return; // user cancel: re-enable silently
|
||||
setAiEditError(getErrorMessage(err) || t("workflows.aiFailed", "Failed to design workflow"));
|
||||
} finally {
|
||||
if (aiEditAbortRef.current === controller) aiEditAbortRef.current = null;
|
||||
setAiEditBusy(false);
|
||||
}
|
||||
}, [
|
||||
activeWorkflow,
|
||||
isBuiltin,
|
||||
aiEditPrompt,
|
||||
projectId,
|
||||
confirm,
|
||||
t,
|
||||
setNodes,
|
||||
setEdges,
|
||||
addToast,
|
||||
]);
|
||||
|
||||
const handleAiEditCancel = useCallback(() => {
|
||||
aiEditAbortRef.current?.abort();
|
||||
setAiEditBusy(false);
|
||||
}, []);
|
||||
|
||||
const updateSelectedData = useCallback(
|
||||
(
|
||||
patch:
|
||||
@@ -1175,6 +1399,44 @@ function InnerEditor({
|
||||
[projectId, addToast, t, closeCreateDialog],
|
||||
);
|
||||
|
||||
// U10/R11: design a brand-new workflow from a prompt in the create dialog.
|
||||
// Calls the server design route (no IR posted), then creates the workflow
|
||||
// seeded from the returned {ir, layout} via the existing create path. The name
|
||||
// comes from the dialog's name field if filled, else "AI: <first 30 chars>".
|
||||
// After activation: interpreterOnly surfaces the existing info banner; a strip
|
||||
// shows the shared importStripped toast (reused per spec). Throws on failure so
|
||||
// the dialog renders the server message inline and stays open (nothing created).
|
||||
const handleDesignNewWorkflow = useCallback(
|
||||
async (prompt: string, dialogName: string, signal: AbortSignal) => {
|
||||
const result = await designWorkflow({ prompt }, projectId, signal);
|
||||
const fallbackName = `AI: ${prompt.slice(0, 30)}`;
|
||||
const workflowName = dialogName || fallbackName;
|
||||
const created = await createWorkflow(
|
||||
{
|
||||
name: workflowName,
|
||||
kind: "workflow",
|
||||
ir: result.ir,
|
||||
layout: result.layout,
|
||||
},
|
||||
projectId,
|
||||
);
|
||||
// Stash the interpreter-only flag BEFORE activating so the new workflow's
|
||||
// load effect re-raises the banner instead of clearing it (U10/R11).
|
||||
pendingInterpreterOnlyRef.current = result.interpreterOnly;
|
||||
setWorkflows((ws) => [...ws, created]);
|
||||
setActiveId(created.id);
|
||||
addToast(t("workflows.created", 'Created workflow "{{name}}"', { name: created.name }), "success");
|
||||
if (result.strippedApprovalFlags) {
|
||||
addToast(
|
||||
t("workflows.importStripped", "Auto-approval flags were removed from imported nodes"),
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
closeCreateDialog();
|
||||
},
|
||||
[projectId, addToast, t, closeCreateDialog],
|
||||
);
|
||||
|
||||
const handleDeleteWorkflow = useCallback(async () => {
|
||||
if (!activeWorkflow) return;
|
||||
if (isBuiltinWorkflowId(activeWorkflow.id)) return; // built-ins are read-only
|
||||
@@ -1734,6 +1996,77 @@ function InnerEditor({
|
||||
))}
|
||||
</div>
|
||||
<div className="wf-editor-actions">
|
||||
{/* U10/R11: "Design with AI" opens a popover panel targeting
|
||||
the active workflow (workflowId). Hidden for built-ins. */}
|
||||
<div className="wf-ai-edit-wrap">
|
||||
<button
|
||||
className="wf-editor-action"
|
||||
data-testid="wf-ai-edit"
|
||||
aria-expanded={aiPanelOpen}
|
||||
onClick={() => {
|
||||
setAiPanelOpen((o) => !o);
|
||||
setAiEditError(null);
|
||||
}}
|
||||
>
|
||||
<Sparkles size={13} /> {t("workflows.aiEdit", "Design with AI")}
|
||||
</button>
|
||||
{aiPanelOpen && (
|
||||
<div
|
||||
className="wf-ai-panel"
|
||||
data-testid="wf-ai-panel"
|
||||
role="dialog"
|
||||
aria-busy={aiEditBusy}
|
||||
aria-label={t("workflows.aiEdit", "Design with AI")}
|
||||
>
|
||||
<textarea
|
||||
className="wf-ai-prompt"
|
||||
data-testid="wf-ai-edit-prompt"
|
||||
rows={3}
|
||||
value={aiEditPrompt}
|
||||
disabled={aiEditBusy}
|
||||
placeholder={t(
|
||||
"workflows.aiPromptPlaceholder",
|
||||
"e.g. Run lint and tests before merge, then post a changelog comment after merge",
|
||||
)}
|
||||
onChange={(e) => {
|
||||
setAiEditPrompt(e.target.value);
|
||||
if (aiEditError) setAiEditError(null);
|
||||
}}
|
||||
/>
|
||||
{aiEditError && (
|
||||
<p className="wf-create-error" role="alert" data-testid="wf-ai-edit-error">
|
||||
{aiEditError}
|
||||
</p>
|
||||
)}
|
||||
<div className="wf-ai-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary wf-ai-submit"
|
||||
data-testid="wf-ai-edit-submit"
|
||||
disabled={aiEditBusy}
|
||||
onClick={() => void handleAiEditSubmit()}
|
||||
>
|
||||
{aiEditBusy ? (
|
||||
<Loader2 size={13} className="wf-spin" />
|
||||
) : (
|
||||
<Sparkles size={13} />
|
||||
)}{" "}
|
||||
{t("workflows.aiSubmit", "Design with AI")}
|
||||
</button>
|
||||
{aiEditBusy && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn wf-ai-cancel"
|
||||
data-testid="wf-ai-edit-cancel"
|
||||
onClick={handleAiEditCancel}
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="wf-editor-action"
|
||||
onClick={handleAutoLayout}
|
||||
@@ -2690,6 +3023,7 @@ function InnerEditor({
|
||||
<CreateWorkflowDialog
|
||||
workflows={workflows}
|
||||
onCreate={handleCreateWorkflow}
|
||||
onDesign={handleDesignNewWorkflow}
|
||||
onClose={closeCreateDialog}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,7 @@ vi.mock("../../api", () => ({
|
||||
compileWorkflow: vi.fn(),
|
||||
exportWorkflow: vi.fn(),
|
||||
importWorkflow: vi.fn(),
|
||||
designWorkflow: vi.fn(),
|
||||
ApiRequestError: class ApiRequestError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status: number) {
|
||||
@@ -33,7 +34,7 @@ vi.mock("../../api", () => ({
|
||||
}));
|
||||
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
import { fetchWorkflows, fetchTraits, fetchStepParsers, updateWorkflow, compileWorkflow, createWorkflow, deleteWorkflow, fetchModels, migrateLegacyWorkflowSteps, exportWorkflow, importWorkflow, ApiRequestError, fetchWorkflowStepTemplates, fetchPluginWorkflowStepTemplates } from "../../api";
|
||||
import { fetchWorkflows, fetchTraits, fetchStepParsers, updateWorkflow, compileWorkflow, createWorkflow, deleteWorkflow, fetchModels, migrateLegacyWorkflowSteps, exportWorkflow, importWorkflow, designWorkflow, ApiRequestError, fetchWorkflowStepTemplates, fetchPluginWorkflowStepTemplates } from "../../api";
|
||||
import type { TraitCatalogEntry } from "../../api";
|
||||
import type { WorkflowStepTemplate } from "@fusion/core";
|
||||
import { WorkflowNodeEditor } from "../WorkflowNodeEditor";
|
||||
@@ -1629,3 +1630,219 @@ describe("WorkflowNodeEditor — U9 palette Templates section", () => {
|
||||
expect(screen.getByTestId("wf-tpl-plugin-acme-scan")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── U10: Design-with-AI editor affordances ──────────────────────────────────
|
||||
|
||||
describe("WorkflowNodeEditor — U10 design-with-AI", () => {
|
||||
// A distinctive designed IR: a single script node so the canvas renders
|
||||
// `wf-node-script` (absent from the active v2Def, which has a prompt node).
|
||||
function designedResult(over: Partial<import("../../api").DesignWorkflowResult> = {}) {
|
||||
return {
|
||||
ir: {
|
||||
version: "v1" as const,
|
||||
name: "AI designed",
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" as const },
|
||||
{ id: "ai-lint", kind: "script" as const, config: { scriptName: "lint" } },
|
||||
{ id: "end", kind: "end" as const },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "ai-lint", condition: "success" as const },
|
||||
{ from: "ai-lint", to: "end", condition: "success" as const },
|
||||
],
|
||||
},
|
||||
layout: { start: { x: 0, y: 0 }, "ai-lint": { x: 120, y: 0 }, end: { x: 240, y: 0 } },
|
||||
interpreterOnly: false,
|
||||
strippedApprovalFlags: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
|
||||
vi.mocked(fetchStepParsers).mockResolvedValue(["step-headings", "json-steps"]);
|
||||
vi.mocked(fetchModels).mockResolvedValue({ models: [] });
|
||||
vi.mocked(migrateLegacyWorkflowSteps).mockResolvedValue({ migrated: 0, skipped: 0 });
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Create dialog flow ─────────────────────────────────────────────────────
|
||||
|
||||
it("toggle reveals the prompt textarea; success creates the workflow from the returned IR and closes the dialog", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(designWorkflow).mockResolvedValue(designedResult());
|
||||
vi.mocked(createWorkflow).mockResolvedValue({ ...v2Def(), id: "WF-AI", name: "AI: do the thing" });
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
fireEvent.click(await screen.findByTestId("wf-new-workflow"));
|
||||
await screen.findByTestId("wf-create-dialog");
|
||||
|
||||
// Textarea hidden until toggled.
|
||||
expect(screen.queryByTestId("wf-ai-prompt")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId("wf-ai-toggle"));
|
||||
const prompt = await screen.findByTestId("wf-ai-prompt");
|
||||
fireEvent.change(prompt, { target: { value: "do the thing" } });
|
||||
fireEvent.click(screen.getByTestId("wf-ai-submit"));
|
||||
|
||||
await waitFor(() => expect(designWorkflow).toHaveBeenCalledWith(
|
||||
{ prompt: "do the thing" },
|
||||
undefined,
|
||||
expect.any(AbortSignal),
|
||||
));
|
||||
await waitFor(() => expect(createWorkflow).toHaveBeenCalled());
|
||||
const [input] = vi.mocked(createWorkflow).mock.calls[0];
|
||||
// createWorkflow seeded from the returned IR (node ids/count match).
|
||||
const ir = (input as { ir: { nodes: Array<{ id: string }> } }).ir;
|
||||
expect(ir.nodes.map((n) => n.id)).toEqual(["start", "ai-lint", "end"]);
|
||||
// Dialog closed.
|
||||
await waitFor(() => expect(screen.queryByTestId("wf-create-dialog")).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("422 rejection shows the inline error; createWorkflow not called; dialog stays open", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(designWorkflow).mockRejectedValue(
|
||||
new ApiRequestError("The AI response was not valid JSON.", 422),
|
||||
);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
fireEvent.click(await screen.findByTestId("wf-new-workflow"));
|
||||
await screen.findByTestId("wf-create-dialog");
|
||||
fireEvent.click(screen.getByTestId("wf-ai-toggle"));
|
||||
fireEvent.change(await screen.findByTestId("wf-ai-prompt"), { target: { value: "bad" } });
|
||||
fireEvent.click(screen.getByTestId("wf-ai-submit"));
|
||||
|
||||
const err = await screen.findByTestId("wf-ai-error");
|
||||
expect(err).toHaveTextContent("The AI response was not valid JSON.");
|
||||
expect(err).toHaveAttribute("role", "alert");
|
||||
expect(createWorkflow).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("wf-create-dialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("in-flight: submit disabled + Cancel visible; cancel aborts and re-enables", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([]);
|
||||
let rejectFn: ((e: unknown) => void) | undefined;
|
||||
vi.mocked(designWorkflow).mockImplementation((_input, _pid, signal) => {
|
||||
return new Promise((_resolve, reject) => {
|
||||
rejectFn = reject;
|
||||
signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
|
||||
});
|
||||
});
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
fireEvent.click(await screen.findByTestId("wf-new-workflow"));
|
||||
await screen.findByTestId("wf-create-dialog");
|
||||
fireEvent.click(screen.getByTestId("wf-ai-toggle"));
|
||||
fireEvent.change(await screen.findByTestId("wf-ai-prompt"), { target: { value: "slow one" } });
|
||||
fireEvent.click(screen.getByTestId("wf-ai-submit"));
|
||||
|
||||
// In-flight: submit disabled, Cancel visible, section aria-busy.
|
||||
await waitFor(() => expect(screen.getByTestId("wf-ai-submit")).toBeDisabled());
|
||||
expect(screen.getByTestId("wf-ai-cancel")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("wf-ai-create")).toHaveAttribute("aria-busy", "true");
|
||||
|
||||
// Cancel aborts → re-enables, no error shown.
|
||||
fireEvent.click(screen.getByTestId("wf-ai-cancel"));
|
||||
await waitFor(() => expect(screen.getByTestId("wf-ai-submit")).not.toBeDisabled());
|
||||
expect(screen.queryByTestId("wf-ai-error")).not.toBeInTheDocument();
|
||||
expect(createWorkflow).not.toHaveBeenCalled();
|
||||
expect(rejectFn).toBeDefined();
|
||||
});
|
||||
|
||||
it("interpreterOnly result seeds the info banner after the workflow loads", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(designWorkflow).mockResolvedValue(designedResult({ interpreterOnly: true }));
|
||||
vi.mocked(createWorkflow).mockResolvedValue({ ...v2Def(), id: "WF-AI", name: "AI branchy" });
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
fireEvent.click(await screen.findByTestId("wf-new-workflow"));
|
||||
await screen.findByTestId("wf-create-dialog");
|
||||
fireEvent.click(screen.getByTestId("wf-ai-toggle"));
|
||||
fireEvent.change(await screen.findByTestId("wf-ai-prompt"), { target: { value: "branchy" } });
|
||||
fireEvent.click(screen.getByTestId("wf-ai-submit"));
|
||||
|
||||
expect(await screen.findByTestId("wf-interpreter-only-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Toolbar flow ───────────────────────────────────────────────────────────
|
||||
|
||||
it("hides the toolbar Design-with-AI button for built-ins", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByTestId("wf-readonly-banner");
|
||||
expect(screen.queryByTestId("wf-ai-edit")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toolbar flow over a clean canvas: success confirms then replaces the graph and leaves it dirty", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
vi.mocked(designWorkflow).mockResolvedValue(designedResult());
|
||||
|
||||
renderWithConfirm(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
// Open the panel and submit against the active workflow (no edits = clean).
|
||||
fireEvent.click(await screen.findByTestId("wf-ai-edit"));
|
||||
fireEvent.change(await screen.findByTestId("wf-ai-edit-prompt"), { target: { value: "rebuild it" } });
|
||||
fireEvent.click(screen.getByTestId("wf-ai-edit-submit"));
|
||||
|
||||
await waitFor(() => expect(designWorkflow).toHaveBeenCalledWith(
|
||||
{ prompt: "rebuild it", workflowId: "WF-002" },
|
||||
undefined,
|
||||
expect.any(AbortSignal),
|
||||
));
|
||||
// Always-confirm replace dialog (even though clean).
|
||||
const dialog = await screen.findByRole("dialog", { name: /Replace graph/i });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: /Replace/i }));
|
||||
|
||||
// Canvas replaced: the designed script node appears.
|
||||
await waitFor(() => expect(screen.getByTestId("wf-node-script")).toBeInTheDocument());
|
||||
// Dirty: closing now prompts the discard guard.
|
||||
fireEvent.click(screen.getByLabelText("Close workflow editor"));
|
||||
expect(await screen.findByRole("dialog", { name: /Discard unsaved changes/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toolbar flow: cancelling the replace confirm keeps the canvas unchanged", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
vi.mocked(designWorkflow).mockResolvedValue(designedResult());
|
||||
|
||||
renderWithConfirm(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
// Make a dirty edit first (inline rename) so we can prove it survives a cancel.
|
||||
fireEvent.click(await screen.findByTestId("wf-workflow-name"));
|
||||
const input = await screen.findByTestId("wf-workflow-name-input");
|
||||
fireEvent.change(input, { target: { value: "Kept name" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
fireEvent.click(screen.getByTestId("wf-ai-edit"));
|
||||
fireEvent.change(await screen.findByTestId("wf-ai-edit-prompt"), { target: { value: "replace pls" } });
|
||||
fireEvent.click(screen.getByTestId("wf-ai-edit-submit"));
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: /Replace graph/i });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: /Cancel/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("dialog", { name: /Replace graph/i })).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// The designed script node was NOT inserted; the prior edit is intact.
|
||||
expect(screen.queryByTestId("wf-node-script")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("wf-workflow-name")).toHaveTextContent("Kept name");
|
||||
});
|
||||
|
||||
it("toolbar flow: 422 shows the inline panel error and leaves the canvas untouched", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
vi.mocked(designWorkflow).mockRejectedValue(
|
||||
new ApiRequestError("Invalid workflow IR", 422),
|
||||
);
|
||||
|
||||
renderWithConfirm(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
fireEvent.click(await screen.findByTestId("wf-ai-edit"));
|
||||
fireEvent.change(await screen.findByTestId("wf-ai-edit-prompt"), { target: { value: "nope" } });
|
||||
fireEvent.click(screen.getByTestId("wf-ai-edit-submit"));
|
||||
|
||||
const err = await screen.findByTestId("wf-ai-edit-error");
|
||||
expect(err).toHaveTextContent("Invalid workflow IR");
|
||||
expect(err).toHaveAttribute("role", "alert");
|
||||
// No replace confirm appeared; canvas unchanged (no script node).
|
||||
expect(screen.queryByRole("dialog", { name: /Replace graph/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("wf-node-script")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6831,6 +6831,15 @@
|
||||
"trivialGraphHint": "This workflow only runs start → end. Add steps from the palette above to build it out."
|
||||
},
|
||||
"workflows": {
|
||||
"aiEdit": "Design with AI",
|
||||
"aiFailed": "Failed to design workflow",
|
||||
"aiPromptPlaceholder": "e.g. Run lint and tests before merge, then post a changelog comment after merge",
|
||||
"aiPromptRequired": "Describe the workflow you want",
|
||||
"aiReplaceConfirm": "Replace the current graph with the AI design? Unsaved changes will be lost.",
|
||||
"aiReplaceConfirmLabel": "Replace",
|
||||
"aiReplaceTitle": "Replace graph?",
|
||||
"aiSubmit": "Design with AI",
|
||||
"aiToggle": "Describe it instead",
|
||||
"clickToEditDescription": "Click to edit description",
|
||||
"clickToRename": "Click to rename",
|
||||
"created": "Created workflow \"{{name}}\"",
|
||||
|
||||
17
packages/i18n/src/resources.d.ts
vendored
17
packages/i18n/src/resources.d.ts
vendored
@@ -6793,6 +6793,7 @@ export default interface Resources {
|
||||
"foreachWorktree": "Per-step worktree",
|
||||
"gateBlocks": "Gate (blocks)",
|
||||
"gateMode": "Gate mode",
|
||||
"insertTemplate": "Insert template {{name}}",
|
||||
"interpreterOnly": "This workflow branches, so it runs on the graph interpreter — it can't compile to the linear step engine, but it will still run.",
|
||||
"joinAll": "All branches",
|
||||
"joinAny": "Any branch",
|
||||
@@ -6822,6 +6823,13 @@ export default interface Resources {
|
||||
"summaryHoldRelease": "Release: {{release}}",
|
||||
"summaryNotConfigured": "Not configured",
|
||||
"summaryReviewType": "{{type}} review",
|
||||
"templateFilterLabel": "Filter templates",
|
||||
"templateFilterPlaceholder": "Filter templates",
|
||||
"templateSeamConflict": "This fragment duplicates the \"{{seam}}\" seam already on the canvas, so it can't be inserted.",
|
||||
"templatesBuiltinSteps": "Built-in steps",
|
||||
"templatesFragments": "Fragments",
|
||||
"templatesPluginSteps": "Plugin steps",
|
||||
"templatesSection": "Templates",
|
||||
"trivialGraphHint": "This workflow only runs start → end. Add steps from the palette above to build it out."
|
||||
},
|
||||
"workflowSelector": {
|
||||
@@ -6831,6 +6839,15 @@ export default interface Resources {
|
||||
"switchConfirm": "Switch and abort"
|
||||
},
|
||||
"workflows": {
|
||||
"aiEdit": "Design with AI",
|
||||
"aiFailed": "Failed to design workflow",
|
||||
"aiPromptPlaceholder": "e.g. Run lint and tests before merge, then post a changelog comment after merge",
|
||||
"aiPromptRequired": "Describe the workflow you want",
|
||||
"aiReplaceConfirm": "Replace the current graph with the AI design? Unsaved changes will be lost.",
|
||||
"aiReplaceConfirmLabel": "Replace",
|
||||
"aiReplaceTitle": "Replace graph?",
|
||||
"aiSubmit": "Design with AI",
|
||||
"aiToggle": "Describe it instead",
|
||||
"clickToEditDescription": "Click to edit description",
|
||||
"clickToRename": "Click to rename",
|
||||
"createDescription": "Description (optional)",
|
||||
|
||||
Reference in New Issue
Block a user