feat(dashboard): design-with-AI affordances in the workflow editor

This commit is contained in:
gsxdsm
2026-06-05 00:32:38 -07:00
parent 0c0387264f
commit 62c820bf99
6 changed files with 688 additions and 3 deletions

View File

@@ -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 }>(

View File

@@ -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));
}

View File

@@ -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}
/>
)}

View File

@@ -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();
});
});

View File

@@ -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}}\"",

View File

@@ -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)",