The node "Auto-approve requests" toggle was captured but unused. The only human-approval pause reachable from a custom node is the CLI first-run trust-on-first-use gate (review-style nodes run as ephemeral readonly agents with no permission gate), so autoApprove now bypasses that pause — a superset of the CLI-specific cliSkipApproval flag. The inspector explains the effect when the toggle is on. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
624 lines
25 KiB
TypeScript
624 lines
25 KiB
TypeScript
import "@xyflow/react/dist/style.css";
|
|
import "./WorkflowNodeEditor.css";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
ReactFlow,
|
|
ReactFlowProvider,
|
|
Background,
|
|
Controls,
|
|
MiniMap,
|
|
addEdge,
|
|
useNodesState,
|
|
useEdgesState,
|
|
type Connection,
|
|
type Node as FlowNode,
|
|
type Edge as FlowEdge,
|
|
} from "@xyflow/react";
|
|
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle } from "lucide-react";
|
|
import type { WorkflowDefinition } from "@fusion/core";
|
|
import { getErrorMessage, isBuiltinWorkflowId } from "@fusion/core";
|
|
import {
|
|
fetchWorkflows,
|
|
createWorkflow,
|
|
updateWorkflow,
|
|
deleteWorkflow,
|
|
compileWorkflow,
|
|
fetchModels,
|
|
fetchAgents,
|
|
fetchDiscoveredSkills,
|
|
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 { useModalResizePersist } from "../hooks/useModalResizePersist";
|
|
import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
|
|
import { irToFlow, flowToIr, emptyWorkflowIr, emptyWorkflowLayout } from "./workflow-flow-mapping";
|
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
|
|
|
type ExecutorKind = "model" | "agent" | "skill" | "cli";
|
|
|
|
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) };
|
|
}
|
|
|
|
interface WorkflowNodeEditorProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
addToast: (message: string, type?: ToastType) => void;
|
|
projectId?: string;
|
|
}
|
|
|
|
let nodeSeq = 0;
|
|
function newNodeId(): string {
|
|
nodeSeq += 1;
|
|
return `n-${Date.now().toString(36)}-${nodeSeq}`;
|
|
}
|
|
|
|
const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof MessageSquare; presetConfig?: Record<string, unknown> }> = [
|
|
{ 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 },
|
|
];
|
|
|
|
function InnerEditor({
|
|
onClose,
|
|
addToast,
|
|
projectId,
|
|
modalRef,
|
|
}: Omit<WorkflowNodeEditorProps, "isOpen"> & { modalRef: React.RefObject<HTMLDivElement | null> }) {
|
|
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
|
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [validationError, setValidationError] = useState<string | null>(null);
|
|
const [nodes, setNodes, onNodesChange] = useNodesState<FlowNode<WorkflowFlowNodeData>>([]);
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState<FlowEdge>([]);
|
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
|
|
|
const activeWorkflow = useMemo(() => workflows.find((w) => w.id === activeId), [workflows, activeId]);
|
|
const isBuiltin = !!activeWorkflow && isBuiltinWorkflowId(activeWorkflow.id);
|
|
|
|
const loadWorkflows = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await fetchWorkflows(projectId);
|
|
setWorkflows(data);
|
|
setActiveId((prev) => prev ?? data[0]?.id ?? null);
|
|
} catch (err) {
|
|
addToast(getErrorMessage(err) || "Failed to load workflows", "error");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [projectId, addToast]);
|
|
|
|
useEffect(() => {
|
|
void loadWorkflows();
|
|
}, [loadWorkflows]);
|
|
|
|
// Load the active workflow graph into the canvas.
|
|
useEffect(() => {
|
|
if (!activeWorkflow) {
|
|
setNodes([]);
|
|
setEdges([]);
|
|
return;
|
|
}
|
|
const flow = irToFlow(activeWorkflow);
|
|
setNodes(flow.nodes);
|
|
setEdges(flow.edges);
|
|
setSelectedNodeId(null);
|
|
setValidationError(null);
|
|
}, [activeWorkflow, setNodes, setEdges]);
|
|
|
|
const onConnect = useCallback(
|
|
(connection: Connection) => {
|
|
setEdges((eds) =>
|
|
addEdge({ ...connection, label: "success", data: { condition: "success" } }, eds),
|
|
);
|
|
},
|
|
[setEdges],
|
|
);
|
|
|
|
const addNode = useCallback(
|
|
(kind: WorkflowEditorNodeKind, nodeLabel?: string, presetConfig?: Record<string, unknown>) => {
|
|
const id = newNodeId();
|
|
const label = nodeLabel ?? (kind === "merge" ? "Merge boundary" : kind.charAt(0).toUpperCase() + kind.slice(1));
|
|
const baseConfig = kind === "gate" ? { gateMode: "gate" } : {};
|
|
const config = presetConfig ? { ...baseConfig, ...presetConfig } : baseConfig;
|
|
setNodes((ns) => [
|
|
...ns,
|
|
{
|
|
id,
|
|
type: kind,
|
|
position: { x: 200 + ns.length * 40, y: 240 + (ns.length % 3) * 70 },
|
|
data: { kind, label, config },
|
|
deletable: true,
|
|
},
|
|
]);
|
|
setSelectedNodeId(id);
|
|
},
|
|
[setNodes],
|
|
);
|
|
|
|
const updateSelectedData = useCallback(
|
|
(patch: Partial<WorkflowFlowNodeData> | { config: Record<string, unknown> }) => {
|
|
if (!selectedNodeId) return;
|
|
setNodes((ns) =>
|
|
ns.map((n) =>
|
|
n.id === selectedNodeId
|
|
? {
|
|
...n,
|
|
data: {
|
|
...n.data,
|
|
...("config" in patch ? { config: { ...n.data.config, ...patch.config } } : patch),
|
|
},
|
|
}
|
|
: n,
|
|
),
|
|
);
|
|
},
|
|
[selectedNodeId, setNodes],
|
|
);
|
|
|
|
const handleCreateWorkflow = useCallback(async () => {
|
|
const name = window.prompt("New workflow name");
|
|
if (!name?.trim()) return;
|
|
try {
|
|
const created = await createWorkflow(
|
|
{ name: name.trim(), ir: emptyWorkflowIr(name.trim()), layout: emptyWorkflowLayout() },
|
|
projectId,
|
|
);
|
|
setWorkflows((ws) => [...ws, created]);
|
|
setActiveId(created.id);
|
|
addToast(`Created workflow "${created.name}"`, "success");
|
|
} catch (err) {
|
|
addToast(getErrorMessage(err) || "Failed to create workflow", "error");
|
|
}
|
|
}, [projectId, addToast]);
|
|
|
|
const handleDeleteWorkflow = useCallback(async () => {
|
|
if (!activeWorkflow) return;
|
|
if (isBuiltinWorkflowId(activeWorkflow.id)) return; // built-ins are read-only
|
|
if (!window.confirm(`Delete workflow "${activeWorkflow.name}"?`)) return;
|
|
try {
|
|
await deleteWorkflow(activeWorkflow.id, projectId);
|
|
setWorkflows((ws) => ws.filter((w) => w.id !== activeWorkflow.id));
|
|
setActiveId(null);
|
|
addToast("Workflow deleted", "success");
|
|
} catch (err) {
|
|
addToast(getErrorMessage(err) || "Failed to delete workflow", "error");
|
|
}
|
|
}, [activeWorkflow, projectId, addToast]);
|
|
|
|
const handleDuplicate = useCallback(async () => {
|
|
if (!activeWorkflow) return;
|
|
try {
|
|
const created = await createWorkflow(
|
|
{
|
|
name: `${activeWorkflow.name} (copy)`,
|
|
description: activeWorkflow.description,
|
|
ir: activeWorkflow.ir,
|
|
layout: activeWorkflow.layout,
|
|
},
|
|
projectId,
|
|
);
|
|
setWorkflows((ws) => [...ws, created]);
|
|
setActiveId(created.id);
|
|
addToast(`Duplicated to "${created.name}" — editable`, "success");
|
|
} catch (err) {
|
|
addToast(getErrorMessage(err) || "Failed to duplicate workflow", "error");
|
|
}
|
|
}, [activeWorkflow, projectId, addToast]);
|
|
|
|
const handleSave = useCallback(async () => {
|
|
if (!activeWorkflow) return;
|
|
if (isBuiltinWorkflowId(activeWorkflow.id)) return; // built-ins are read-only
|
|
setSaving(true);
|
|
setValidationError(null);
|
|
try {
|
|
const { ir, layout } = flowToIr(activeWorkflow.name, nodes, edges);
|
|
const updated = await updateWorkflow(activeWorkflow.id, { ir, layout }, projectId);
|
|
setWorkflows((ws) => ws.map((w) => (w.id === updated.id ? updated : w)));
|
|
// Validate by compiling — surfaces non-linear graphs as a banner.
|
|
try {
|
|
await compileWorkflow(updated.id, projectId);
|
|
addToast("Workflow saved", "success");
|
|
} catch (compileErr) {
|
|
setValidationError(getErrorMessage(compileErr) || "Workflow saved but cannot be compiled");
|
|
}
|
|
} catch (err) {
|
|
const message = getErrorMessage(err) || "Failed to save workflow";
|
|
setValidationError(message);
|
|
addToast(message, "error");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [activeWorkflow, nodes, edges, projectId, addToast]);
|
|
|
|
const selectedNode = nodes.find((n) => n.id === selectedNodeId) ?? null;
|
|
|
|
// Lazy-loaded executor resources
|
|
const [models, setModels] = useState<ModelInfo[]>([]);
|
|
const [agents, setAgents] = useState<Agent[]>([]);
|
|
const [skills, setSkills] = useState<DiscoveredSkill[]>([]);
|
|
|
|
const currentExecutor = (selectedNode?.data.config?.executor as ExecutorKind | undefined) ?? "model";
|
|
|
|
useEffect(() => {
|
|
if (!selectedNode || (selectedNode.data.kind !== "prompt" && selectedNode.data.kind !== "gate")) return;
|
|
if (currentExecutor === "model" && models.length === 0) {
|
|
fetchModels().then((res) => setModels(res.models)).catch((err) => {
|
|
addToast(getErrorMessage(err) || "Failed to load models", "error");
|
|
});
|
|
} else if (currentExecutor === "agent" && agents.length === 0) {
|
|
fetchAgents().then(setAgents).catch((err) => {
|
|
addToast(getErrorMessage(err) || "Failed to load agents", "error");
|
|
});
|
|
} else if (currentExecutor === "skill" && skills.length === 0) {
|
|
fetchDiscoveredSkills(projectId).then(setSkills).catch((err) => {
|
|
addToast(getErrorMessage(err) || "Failed to load skills", "error");
|
|
});
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [currentExecutor, selectedNode?.id]);
|
|
|
|
const overlayProps = useOverlayDismiss(onClose);
|
|
|
|
return (
|
|
<div className="modal-overlay wf-editor-overlay" {...overlayProps}>
|
|
<div className="modal wf-editor-modal" ref={modalRef} onClick={(e) => e.stopPropagation()}>
|
|
<header className="wf-editor-header">
|
|
<h2>Workflows</h2>
|
|
<button className="wf-editor-close" onClick={onClose} aria-label="Close workflow editor">
|
|
<X size={18} />
|
|
</button>
|
|
</header>
|
|
|
|
<div className="wf-editor-body">
|
|
<aside className="wf-editor-sidebar">
|
|
<button className="wf-editor-new" onClick={handleCreateWorkflow}>
|
|
<Plus size={14} /> New workflow
|
|
</button>
|
|
{loading ? (
|
|
<div className="wf-editor-empty">
|
|
<Loader2 size={16} className="wf-spin" /> Loading…
|
|
</div>
|
|
) : workflows.length === 0 ? (
|
|
<div className="wf-editor-empty">No workflows yet.</div>
|
|
) : (
|
|
<ul className="wf-editor-list">
|
|
{workflows.map((w) => (
|
|
<li key={w.id}>
|
|
<button
|
|
className={`wf-editor-list-item${w.id === activeId ? " active" : ""}`}
|
|
onClick={() => setActiveId(w.id)}
|
|
>
|
|
{w.name}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</aside>
|
|
|
|
<section className="wf-editor-canvas-wrap">
|
|
{activeWorkflow ? (
|
|
<>
|
|
<div className="wf-editor-toolbar">
|
|
<div className="wf-editor-palette">
|
|
{PALETTE.map(({ kind, label, icon: Icon, presetConfig }) => (
|
|
<button
|
|
key={label}
|
|
className="wf-palette-btn"
|
|
onClick={() => addNode(kind, label, presetConfig)}
|
|
disabled={isBuiltin}
|
|
title={isBuiltin ? "Built-in workflows are read-only — duplicate to edit" : undefined}
|
|
>
|
|
<Icon size={13} /> {label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="wf-editor-actions">
|
|
{isBuiltin ? (
|
|
<>
|
|
<span className="wf-editor-readonly-note" role="status">
|
|
Read-only built-in
|
|
</span>
|
|
<button className="wf-editor-save" onClick={handleDuplicate}>
|
|
<Plus size={13} /> Duplicate to edit
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<button className="wf-editor-delete" onClick={handleDeleteWorkflow}>
|
|
<Trash2 size={13} /> Delete
|
|
</button>
|
|
<button className="wf-editor-save" onClick={handleSave} disabled={saving}>
|
|
{saving ? <Loader2 size={13} className="wf-spin" /> : <Save size={13} />} Save
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{validationError && (
|
|
<div className="wf-editor-banner" role="alert">
|
|
{validationError}
|
|
</div>
|
|
)}
|
|
|
|
<div className="wf-editor-canvas">
|
|
<ReactFlow
|
|
nodes={nodes}
|
|
edges={edges}
|
|
nodeTypes={workflowNodeTypes}
|
|
onNodesChange={onNodesChange}
|
|
onEdgesChange={onEdgesChange}
|
|
onConnect={onConnect}
|
|
onNodeClick={(_, node) => setSelectedNodeId(node.id)}
|
|
onPaneClick={() => setSelectedNodeId(null)}
|
|
fitView
|
|
>
|
|
<Background />
|
|
<Controls />
|
|
<MiniMap pannable zoomable />
|
|
</ReactFlow>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="wf-editor-empty wf-editor-canvas-empty">
|
|
Select or create a workflow to start editing.
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{selectedNode && selectedNode.data.kind !== "start" && selectedNode.data.kind !== "end" && (
|
|
<aside className="wf-editor-inspector">
|
|
<h3>Node</h3>
|
|
{isBuiltin && (
|
|
<p className="wf-inspector-note wf-inspector-note--info">
|
|
Read-only built-in — duplicate the workflow to edit nodes.
|
|
</p>
|
|
)}
|
|
<fieldset className="wf-inspector-fields" disabled={isBuiltin}>
|
|
<label className="wf-field">
|
|
<span>Name</span>
|
|
<input
|
|
value={selectedNode.data.label}
|
|
onChange={(e) => updateSelectedData({ label: e.target.value })}
|
|
/>
|
|
</label>
|
|
|
|
{selectedNode.data.kind === "prompt" || selectedNode.data.kind === "gate" ? (
|
|
<label className="wf-field">
|
|
<span>Prompt</span>
|
|
<textarea
|
|
rows={5}
|
|
value={String(selectedNode.data.config?.prompt ?? "")}
|
|
onChange={(e) => updateSelectedData({ config: { prompt: e.target.value } })}
|
|
/>
|
|
</label>
|
|
) : null}
|
|
|
|
{selectedNode.data.kind === "prompt" ? (
|
|
<>
|
|
<label className="wf-field">
|
|
<span>Executor</span>
|
|
<select
|
|
value={currentExecutor}
|
|
onChange={(e) => updateSelectedData({ config: { executor: e.target.value } })}
|
|
>
|
|
<option value="model">Model</option>
|
|
<option value="agent">Agent</option>
|
|
<option value="skill">Skill</option>
|
|
<option value="cli">CLI / script</option>
|
|
</select>
|
|
</label>
|
|
|
|
{currentExecutor === "model" && (
|
|
<label className="wf-field">
|
|
<span>Model</span>
|
|
<CustomModelDropdown
|
|
label="Model"
|
|
models={models}
|
|
value={getModelDropdownValue(
|
|
String(selectedNode.data.config?.modelProvider ?? ""),
|
|
String(selectedNode.data.config?.modelId ?? ""),
|
|
)}
|
|
onChange={(value) => {
|
|
const { provider, modelId } = parseModelDropdownValue(value);
|
|
updateSelectedData({ config: { modelProvider: provider || undefined, modelId: modelId || undefined } });
|
|
}}
|
|
/>
|
|
</label>
|
|
)}
|
|
|
|
{currentExecutor === "agent" && (
|
|
<label className="wf-field">
|
|
<span>Agent</span>
|
|
<select
|
|
value={String(selectedNode.data.config?.agentId ?? "")}
|
|
onChange={(e) => updateSelectedData({ config: { agentId: e.target.value || undefined } })}
|
|
>
|
|
<option value="">— select agent —</option>
|
|
{agents.map((a) => (
|
|
<option key={a.id} value={a.id}>{a.name}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
)}
|
|
|
|
{currentExecutor === "skill" && (
|
|
<label className="wf-field">
|
|
<span>Skill</span>
|
|
<select
|
|
value={String(selectedNode.data.config?.skillName ?? "")}
|
|
onChange={(e) => updateSelectedData({ config: { skillName: e.target.value || undefined } })}
|
|
>
|
|
<option value="">— select skill —</option>
|
|
{skills.map((s) => (
|
|
<option key={s.id} value={s.name}>{s.name}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
)}
|
|
|
|
{currentExecutor === "cli" && (
|
|
<>
|
|
<label className="wf-field">
|
|
<span>CLI mode</span>
|
|
<select
|
|
value={String(selectedNode.data.config?.cliMode ?? "command")}
|
|
onChange={(e) => updateSelectedData({ config: { cliMode: e.target.value } })}
|
|
>
|
|
<option value="command">Command</option>
|
|
<option value="script">Named script</option>
|
|
</select>
|
|
</label>
|
|
{(selectedNode.data.config?.cliMode ?? "command") === "command" ? (
|
|
<label className="wf-field">
|
|
<span>Command</span>
|
|
<textarea
|
|
rows={3}
|
|
placeholder="npm test -- --runInBand"
|
|
value={String(selectedNode.data.config?.cliCommand ?? "")}
|
|
onChange={(e) => updateSelectedData({ config: { cliCommand: e.target.value } })}
|
|
/>
|
|
<p className="wf-inspector-note wf-inspector-note--info">
|
|
Runs an arbitrary command in the task worktree. The first time this exact command runs, the task pauses for your approval. The node prompt is passed via FUSION_NODE_PROMPT.
|
|
</p>
|
|
<label className="wf-field wf-field--checkbox">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedNode.data.config?.cliSkipApproval === true}
|
|
onChange={(e) => updateSelectedData({ config: { cliSkipApproval: e.target.checked } })}
|
|
/>
|
|
<span>Skip first-run approval (runs without pausing)</span>
|
|
</label>
|
|
</label>
|
|
) : (
|
|
<label className="wf-field">
|
|
<span>Script name</span>
|
|
<input
|
|
value={String(selectedNode.data.config?.scriptName ?? "")}
|
|
onChange={(e) => updateSelectedData({ config: { scriptName: e.target.value } })}
|
|
/>
|
|
<span className="wf-inspector-note">Named script from project settings. The node prompt is passed via FUSION_NODE_PROMPT.</span>
|
|
</label>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<label className="wf-field wf-field--checkbox">
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(selectedNode.data.config?.autoApprove)}
|
|
onChange={(e) => updateSelectedData({ config: { autoApprove: e.target.checked } })}
|
|
/>
|
|
<span>Auto-approve requests</span>
|
|
</label>
|
|
{Boolean(selectedNode.data.config?.autoApprove) && (
|
|
<p className="wf-inspector-note">
|
|
Runs without pausing for approval — e.g. a CLI command executes on its first run without waiting for your sign-off.
|
|
</p>
|
|
)}
|
|
|
|
<label className="wf-field">
|
|
<span>Max retries</span>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={10}
|
|
placeholder="default"
|
|
value={selectedNode.data.config?.maxRetries != null ? String(selectedNode.data.config.maxRetries) : ""}
|
|
onChange={(e) => {
|
|
const val = e.target.value.trim();
|
|
if (val === "") {
|
|
const patch: Record<string, unknown> = { ...selectedNode.data.config };
|
|
delete patch.maxRetries;
|
|
updateSelectedData({ config: patch });
|
|
} else {
|
|
const num = parseInt(val, 10);
|
|
if (!isNaN(num)) updateSelectedData({ config: { maxRetries: num } });
|
|
}
|
|
}}
|
|
/>
|
|
</label>
|
|
|
|
<label className="wf-field wf-field--checkbox">
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(selectedNode.data.config?.awaitInput)}
|
|
onChange={(e) => updateSelectedData({ config: { awaitInput: e.target.checked } })}
|
|
/>
|
|
<span>Wait for user input</span>
|
|
</label>
|
|
{Boolean(selectedNode.data.config?.awaitInput) && (
|
|
<p className="wf-inspector-note wf-inspector-note--info">
|
|
This node pauses the task until you reply in the task's comments and unpause. The Prompt field above is shown to the user as the question.
|
|
</p>
|
|
)}
|
|
</>
|
|
) : null}
|
|
|
|
{selectedNode.data.kind === "script" ? (
|
|
<label className="wf-field">
|
|
<span>Script name</span>
|
|
<input
|
|
value={String(selectedNode.data.config?.scriptName ?? "")}
|
|
onChange={(e) => updateSelectedData({ config: { scriptName: e.target.value } })}
|
|
/>
|
|
</label>
|
|
) : null}
|
|
|
|
{selectedNode.data.kind !== "merge" ? (
|
|
<label className="wf-field">
|
|
<span>Gate mode</span>
|
|
<select
|
|
// Default display must match the compiler's defaults:
|
|
// gate and script nodes block by default, prompt is advisory.
|
|
value={String(
|
|
selectedNode.data.config?.gateMode
|
|
?? (selectedNode.data.kind === "prompt" ? "advisory" : "gate"),
|
|
)}
|
|
onChange={(e) => updateSelectedData({ config: { gateMode: e.target.value } })}
|
|
>
|
|
<option value="advisory">Advisory</option>
|
|
<option value="gate">Gate (blocks)</option>
|
|
</select>
|
|
</label>
|
|
) : (
|
|
<p className="wf-inspector-note">
|
|
Steps before this marker run pre-merge; steps after run post-merge.
|
|
</p>
|
|
)}
|
|
</fieldset>
|
|
</aside>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function WorkflowNodeEditor({ isOpen, ...rest }: WorkflowNodeEditorProps) {
|
|
const modalRef = useRef<HTMLDivElement>(null);
|
|
useModalResizePersist(modalRef, isOpen, "fusion:workflow-node-editor-size");
|
|
if (!isOpen) return null;
|
|
return (
|
|
<ReactFlowProvider>
|
|
<InnerEditor {...rest} modalRef={modalRef} />
|
|
</ReactFlowProvider>
|
|
);
|
|
}
|