diff --git a/docs/pr-screenshots/duplicate-block-menu-desktop.png b/docs/pr-screenshots/duplicate-block-menu-desktop.png
new file mode 100644
index 000000000..39dd855f2
Binary files /dev/null and b/docs/pr-screenshots/duplicate-block-menu-desktop.png differ
diff --git a/docs/pr-screenshots/duplicate-block-menu-mobile.png b/docs/pr-screenshots/duplicate-block-menu-mobile.png
new file mode 100644
index 000000000..c0427da48
Binary files /dev/null and b/docs/pr-screenshots/duplicate-block-menu-mobile.png differ
diff --git a/docs/pr-screenshots/oss-duplicate-menu.png b/docs/pr-screenshots/oss-duplicate-menu.png
new file mode 100644
index 000000000..dfebe2a39
Binary files /dev/null and b/docs/pr-screenshots/oss-duplicate-menu.png differ
diff --git a/docs/pr-screenshots/oss-duplicate-result.png b/docs/pr-screenshots/oss-duplicate-result.png
new file mode 100644
index 000000000..5c44dad1f
Binary files /dev/null and b/docs/pr-screenshots/oss-duplicate-result.png differ
diff --git a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx
index 2437c3307..2b63bd16d 100644
--- a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx
+++ b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx
@@ -167,6 +167,7 @@ import { useRecordingStore } from "@/store/useRecordingStore";
import { useIsCanvasLocked } from "./controls/useIsCanvasLocked";
import { BlockConfigSidebar } from "./panels/BlockConfigSidebar";
import { STUDIO_COPILOT_TRANSITION_MS } from "../studio/constants";
+import { duplicateBlockBelow } from "./workflowDuplicate";
// Grace period after nodesInitialized before we start tracking changes.
// Allows mount-time effects (ResizeObserver, visibility toggling) to settle.
@@ -1099,6 +1100,31 @@ function FlowRenderer({
[onRequestDeleteNode, readOnly],
);
+ function duplicateNode(id: string) {
+ const result = duplicateBlockBelow({
+ nodes,
+ edges,
+ nodeId: id,
+ generateId: nanoid,
+ generateLabel: generateNodeLabel,
+ });
+
+ if (!result) {
+ return;
+ }
+
+ workflowChangesStore.setHasChanges(true);
+ postHog.capture("builder.block.duplicated", {
+ org_id: workflow.organization_id,
+ position: result.position,
+ source_block_id: id,
+ });
+ doLayout(result.nodes, result.edges);
+ useWorkflowPanelStore
+ .getState()
+ .setSelectedBlockId(result.duplicatedNodeId);
+ }
+
function transmuteNode(id: string, nodeType: string) {
const nodeToTransmute = nodes.find((node) => node.id === id);
@@ -1818,6 +1844,9 @@ function FlowRenderer({
// and flip `setHasChanges(true)` on the main editor's store
// while the user is only inspecting versions.
requestDeleteNodeCallback: readOnly ? () => {} : requestDeleteNode,
+ duplicateNodeCallback: readOnly
+ ? () => {}
+ : (id: string) => setTimeout(() => duplicateNode(id), 0),
// setTimeout(..., 0) escapes the Radix dropdown's pointer-event
// lockout: a synchronous mutation inside the menu's onSelect
// races the menu's close animation and re-renders nodes while
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/NodeActionMenu.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/NodeActionMenu.tsx
index b6f86f250..099888b05 100644
--- a/skyvern-frontend/src/routes/workflows/editor/nodes/NodeActionMenu.tsx
+++ b/skyvern-frontend/src/routes/workflows/editor/nodes/NodeActionMenu.tsx
@@ -17,20 +17,26 @@ import {
import { useRecordingStore } from "@/store/useRecordingStore";
type Props = {
+ duplicateDisabledReason?: string | null;
isDeletable?: boolean;
+ isDuplicable?: boolean;
isScriptable?: boolean;
isCanvasLocked?: boolean;
showScriptText?: string;
onDelete?: () => void;
+ onDuplicate?: () => void;
onShowScript?: () => void;
};
function NodeActionMenu({
+ duplicateDisabledReason = null,
isDeletable = true,
+ isDuplicable = true,
isScriptable = false,
isCanvasLocked = false,
showScriptText,
onDelete,
+ onDuplicate,
onShowScript,
}: Props) {
const recordingStore = useRecordingStore();
@@ -41,11 +47,34 @@ function NodeActionMenu({
: isCanvasLocked
? "Unlock canvas to delete blocks"
: null;
+ const duplicateGated =
+ isRecording || isCanvasLocked || Boolean(duplicateDisabledReason);
+ const duplicateGateReason = isRecording
+ ? "Stop recording to duplicate blocks"
+ : isCanvasLocked
+ ? "Unlock canvas to duplicate blocks"
+ : duplicateDisabledReason;
- if (!isDeletable && !isScriptable) {
+ if (!isDeletable && !isDuplicable && !isScriptable) {
return null;
}
+ const duplicateItem =
+ isDuplicable && onDuplicate ? (
+ {
+ if (duplicateGated) {
+ event.preventDefault();
+ return;
+ }
+ onDuplicate();
+ }}
+ >
+ Duplicate Block
+
+ ) : null;
+
const deleteItem = isDeletable ? (
-
+
Block Actions
+ {duplicateItem && duplicateGated && duplicateGateReason ? (
+
+
+
+ {duplicateItem}
+
+ {duplicateGateReason}
+
+
+ ) : (
+ duplicateItem
+ )}
{deleteItem && deleteGated && deleteGateReason ? (
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx
index 22a107a2c..643c59457 100644
--- a/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx
+++ b/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx
@@ -27,6 +27,7 @@ import { useOnChange } from "@/hooks/useOnChange";
import { useAutoplayStore } from "@/store/useAutoplayStore";
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
+import { useDuplicateNodeCallback } from "@/routes/workflows/hooks/useDuplicateNodeCallback";
import { useRequestDeleteNodeCallback } from "@/routes/workflows/hooks/useRequestDeleteNodeCallback";
import { useTransmuteNodeCallback } from "@/routes/workflows/hooks/useTransmuteNodeCallback";
import { useToggleScriptForNodeCallback } from "@/routes/workflows/hooks/useToggleScriptForNodeCallback";
@@ -259,6 +260,7 @@ function NodeHeader({
initialValue: blockLabel,
});
const blockTitle = blockTitleOverride ?? workflowBlockTitle[type];
+ const duplicateNodeCallback = useDuplicateNodeCallback();
const requestDeleteNodeCallback = useRequestDeleteNodeCallback();
const transmuteNodeCallback = useTransmuteNodeCallback();
const toggleScriptForNodeCallback = useToggleScriptForNodeCallback();
@@ -817,6 +819,12 @@ function NodeHeader({
const isReadOnlyScope = useWorkflowScopeReadOnly();
const isCanvasLocked = useIsCanvasLocked();
const dragGatedByMode = isDragGatedByMode({ isRecording, isCanvasLocked });
+ const duplicateDisabledReason = isBlockFinallyGated(
+ blockLabel,
+ workflowSettingsStore.finallyBlockLabel,
+ )
+ ? "Finally block must run last"
+ : null;
// Read-only canvases (compare/diff) drop the grip entirely - the handle
// is inert there, so a faded button is just visual noise.
@@ -1045,8 +1053,19 @@ function NodeHeader({
})}
>
{
+ duplicateNodeCallback(nodeId);
+ }
+ }
onDelete={() => {
requestDeleteNodeCallback(nodeId, blockLabel);
}}
diff --git a/skyvern-frontend/src/routes/workflows/editor/workflowDuplicate.test.ts b/skyvern-frontend/src/routes/workflows/editor/workflowDuplicate.test.ts
new file mode 100644
index 000000000..f7f9d197c
--- /dev/null
+++ b/skyvern-frontend/src/routes/workflows/editor/workflowDuplicate.test.ts
@@ -0,0 +1,562 @@
+import type { Edge } from "@xyflow/react";
+import { describe, expect, it } from "vitest";
+
+import type { AppNode } from "./nodes";
+import { duplicateBlockBelow } from "./workflowDuplicate";
+
+function idGenerator(prefix = "new") {
+ let count = 0;
+ return () => `${prefix}-${++count}`;
+}
+
+function labelGenerator(existingLabels: Array) {
+ const existing = new Set(existingLabels);
+ let index = 1;
+ while (existing.has(`block_${index}`)) {
+ index += 1;
+ }
+ return `block_${index}`;
+}
+
+function block(
+ id: string,
+ label: string,
+ overrides: Omit, "data"> & {
+ data?: Record;
+ } = {},
+): AppNode {
+ return {
+ id,
+ type: "navigation",
+ position: { x: 0, y: 0 },
+ data: {
+ debuggable: true,
+ editable: true,
+ label,
+ continueOnFailure: false,
+ model: null,
+ parameterKeys: [],
+ prompt: "",
+ url: "",
+ navigationGoal: "",
+ errorCodeMapping: "null",
+ allowDownloads: false,
+ downloadSuffix: null,
+ maxRetries: null,
+ maxStepsOverride: null,
+ totpIdentifier: null,
+ totpVerificationUrl: null,
+ disableCache: false,
+ completeCriterion: "",
+ terminateCriterion: "",
+ engine: null,
+ legacyV2Available: false,
+ includeActionHistoryInVerification: false,
+ maxSteps: 10,
+ ...((overrides.data as Record | undefined) ?? {}),
+ },
+ ...overrides,
+ } as AppNode;
+}
+
+function edge(
+ id: string,
+ source: string,
+ target: string,
+ overrides: Partial = {},
+): Edge {
+ return {
+ id,
+ source,
+ target,
+ type: "edgeWithAddButton",
+ ...overrides,
+ };
+}
+
+describe("duplicateBlockBelow", () => {
+ it("copies a block below itself and rewires the execution edge", () => {
+ const nodes = [block("a", "block_1"), block("b", "block_2")];
+ const edges = [edge("a-b", "a", "b")];
+
+ const result = duplicateBlockBelow({
+ nodes,
+ edges,
+ nodeId: "a",
+ generateId: idGenerator(),
+ generateLabel: labelGenerator,
+ });
+
+ expect(result?.duplicatedNodeId).toBe("new-1");
+ expect(result?.duplicatedLabel).toBe("block_3");
+ expect(result?.nodes.map((node) => node.id)).toEqual(["a", "new-1", "b"]);
+ expect(result?.nodes.find((node) => node.id === "new-1")?.data).toEqual(
+ expect.objectContaining({
+ label: "block_3",
+ navigationGoal: "",
+ }),
+ );
+ expect(result?.edges).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ source: "a", target: "new-1" }),
+ expect.objectContaining({ source: "new-1", target: "b" }),
+ ]),
+ );
+ expect(result?.edges.some((candidate) => candidate.id === "a-b")).toBe(
+ false,
+ );
+ });
+
+ it("preserves inactive conditional branch edges when duplicating within the active branch", () => {
+ const nodes = [
+ block("conditional", "block_1", {
+ type: "conditional",
+ data: {
+ debuggable: true,
+ editable: true,
+ label: "block_1",
+ continueOnFailure: false,
+ model: null,
+ branches: [
+ {
+ id: "branch-a",
+ criteria: null,
+ description: null,
+ is_default: false,
+ next_block_label: null,
+ },
+ {
+ id: "branch-b",
+ criteria: null,
+ description: null,
+ is_default: true,
+ next_block_label: null,
+ },
+ ],
+ activeBranchId: "branch-a",
+ mergeLabel: null,
+ },
+ }),
+ block("a", "block_2", {
+ parentId: "conditional",
+ data: {
+ debuggable: true,
+ editable: true,
+ label: "block_2",
+ continueOnFailure: false,
+ model: null,
+ conditionalBranchId: "branch-a",
+ conditionalLabel: "block_1",
+ conditionalMergeLabel: null,
+ conditionalNodeId: "conditional",
+ },
+ }),
+ block("next", "block_3", { parentId: "conditional" }),
+ ];
+ const edges = [
+ edge("a-next-active", "a", "next", {
+ data: {
+ conditionalBranchId: "branch-a",
+ conditionalNodeId: "conditional",
+ },
+ }),
+ edge("a-next-inactive", "a", "next", {
+ data: {
+ conditionalBranchId: "branch-b",
+ conditionalNodeId: "conditional",
+ },
+ }),
+ ];
+
+ const result = duplicateBlockBelow({
+ nodes,
+ edges,
+ nodeId: "a",
+ generateId: idGenerator(),
+ generateLabel: labelGenerator,
+ });
+
+ expect(result?.edges).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: "a-next-inactive" }),
+ expect.objectContaining({
+ source: "a",
+ target: "new-1",
+ data: {
+ conditionalBranchId: "branch-a",
+ conditionalNodeId: "conditional",
+ },
+ }),
+ expect.objectContaining({
+ source: "new-1",
+ target: "next",
+ data: {
+ conditionalBranchId: "branch-a",
+ conditionalNodeId: "conditional",
+ },
+ }),
+ ]),
+ );
+ expect(result?.edges.find((edge) => edge.source === "a")).toEqual(
+ expect.objectContaining({
+ target: "new-1",
+ data: {
+ conditionalBranchId: "branch-a",
+ conditionalNodeId: "conditional",
+ },
+ }),
+ );
+ expect(
+ result?.edges.some((candidate) => candidate.id === "a-next-active"),
+ ).toBe(false);
+ });
+
+ it("duplicates container descendants, remaps branch ids, and rewrites internal output references", () => {
+ const nodes = [
+ block("conditional", "block_1", {
+ type: "conditional",
+ data: {
+ debuggable: true,
+ editable: true,
+ label: "block_1",
+ continueOnFailure: false,
+ model: null,
+ branches: [
+ {
+ id: "branch-a",
+ criteria: null,
+ description: null,
+ is_default: false,
+ next_block_label: null,
+ },
+ {
+ id: "branch-default",
+ criteria: null,
+ description: null,
+ is_default: true,
+ next_block_label: null,
+ },
+ ],
+ activeBranchId: "branch-a",
+ mergeLabel: null,
+ },
+ }),
+ {
+ id: "start",
+ type: "start",
+ parentId: "conditional",
+ position: { x: 0, y: 0 },
+ data: {
+ withWorkflowSettings: false,
+ editable: true,
+ label: "__start_block__",
+ showCode: false,
+ parentNodeType: "conditional",
+ },
+ } as AppNode,
+ block("child", "block_2", {
+ hidden: false,
+ parentId: "conditional",
+ data: {
+ debuggable: true,
+ editable: true,
+ label: "block_2",
+ continueOnFailure: false,
+ model: null,
+ conditionalBranchId: "branch-a",
+ conditionalLabel: "block_1",
+ conditionalMergeLabel: null,
+ conditionalNodeId: "conditional",
+ navigationGoal: "Use {{ block_1_output }}",
+ parameterKeys: ["block_1_output"],
+ },
+ }),
+ {
+ id: "adder",
+ type: "nodeAdder",
+ parentId: "conditional",
+ position: { x: 0, y: 0 },
+ data: {},
+ } as AppNode,
+ block("after", "block_3"),
+ ];
+ const edges = [
+ edge("conditional-after", "conditional", "after"),
+ edge("start-child", "start", "child", {
+ data: {
+ conditionalBranchId: "branch-a",
+ conditionalNodeId: "conditional",
+ },
+ }),
+ edge("child-adder", "child", "adder", {
+ type: "default",
+ data: {
+ conditionalBranchId: "branch-a",
+ conditionalNodeId: "conditional",
+ },
+ }),
+ ];
+
+ const result = duplicateBlockBelow({
+ nodes,
+ edges,
+ nodeId: "conditional",
+ generateId: idGenerator(),
+ generateLabel: labelGenerator,
+ });
+
+ const clonedConditional = result?.nodes.find((node) => node.id === "new-1");
+ const clonedStart = result?.nodes.find((node) => node.id === "new-2");
+ const clonedChild = result?.nodes.find((node) => node.id === "new-3");
+ const clonedAdder = result?.nodes.find((node) => node.id === "new-4");
+
+ expect(clonedConditional?.data.label).toBe("block_4");
+ expect(clonedStart?.parentId).toBe("new-1");
+ expect(clonedChild?.parentId).toBe("new-1");
+ expect(clonedChild?.hidden).toBe(false);
+ expect(clonedAdder?.parentId).toBe("new-1");
+ expect(clonedChild?.data).toEqual(
+ expect.objectContaining({
+ conditionalBranchId: "new-5",
+ conditionalLabel: "block_4",
+ conditionalNodeId: "new-1",
+ label: "block_5",
+ navigationGoal: "Use {{ block_4_output }}",
+ parameterKeys: ["block_4_output"],
+ }),
+ );
+ expect(clonedConditional?.data).toEqual(
+ expect.objectContaining({
+ activeBranchId: "new-5",
+ branches: expect.arrayContaining([
+ expect.objectContaining({ id: "new-5" }),
+ expect.objectContaining({ id: "new-6" }),
+ ]),
+ }),
+ );
+ expect(result?.edges).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ source: "conditional", target: "new-1" }),
+ expect.objectContaining({ source: "new-1", target: "after" }),
+ expect.objectContaining({
+ source: "new-2",
+ target: "new-3",
+ data: {
+ conditionalBranchId: "new-5",
+ conditionalNodeId: "new-1",
+ },
+ }),
+ expect.objectContaining({
+ source: "new-3",
+ target: "new-4",
+ data: {
+ conditionalBranchId: "new-5",
+ conditionalNodeId: "new-1",
+ },
+ }),
+ ]),
+ );
+ });
+
+ it("rewrites cloned code block references to cloned descendant outputs", () => {
+ const nodes = [
+ block("conditional", "block_1", {
+ type: "conditional",
+ data: {
+ debuggable: true,
+ editable: true,
+ label: "block_1",
+ continueOnFailure: false,
+ model: null,
+ branches: [
+ {
+ id: "branch-a",
+ criteria: null,
+ description: null,
+ is_default: true,
+ next_block_label: null,
+ },
+ ],
+ activeBranchId: "branch-a",
+ mergeLabel: null,
+ },
+ }),
+ block("dependency", "block_2", {
+ parentId: "conditional",
+ data: {
+ debuggable: true,
+ editable: true,
+ label: "block_2",
+ continueOnFailure: false,
+ model: null,
+ conditionalBranchId: "branch-a",
+ conditionalLabel: "block_1",
+ conditionalMergeLabel: null,
+ conditionalNodeId: "conditional",
+ },
+ }),
+ block("code", "block_3", {
+ type: "codeBlock",
+ parentId: "conditional",
+ data: {
+ debuggable: true,
+ editable: true,
+ label: "block_3",
+ continueOnFailure: false,
+ model: null,
+ conditionalBranchId: "branch-a",
+ conditionalLabel: "block_1",
+ conditionalMergeLabel: null,
+ conditionalNodeId: "conditional",
+ code:
+ "result = block_2_output\n" +
+ "other = block_20_output\n" +
+ "prefixed = prefix_block_2_output\n" +
+ "nested = data['block_2_output']",
+ prompt: "Use block_2_output and {{ block_2_output }}",
+ parameterKeys: ["block_2_output"],
+ },
+ }),
+ ];
+ const edges = [
+ edge("conditional-dependency", "conditional", "dependency"),
+ edge("dependency-code", "dependency", "code"),
+ ];
+
+ const result = duplicateBlockBelow({
+ nodes,
+ edges,
+ nodeId: "conditional",
+ generateId: idGenerator(),
+ generateLabel: labelGenerator,
+ });
+
+ const clonedCode = result?.nodes.find((node) => node.id === "new-3");
+
+ expect(clonedCode?.data).toEqual(
+ expect.objectContaining({
+ code:
+ "result = block_5_output\n" +
+ "other = block_20_output\n" +
+ "prefixed = prefix_block_2_output\n" +
+ "nested = data['block_5_output']",
+ label: "block_6",
+ parameterKeys: ["block_5_output"],
+ prompt: "Use block_5_output and {{ block_5_output }}",
+ }),
+ );
+ });
+
+ it("preserves inactive conditional branch visibility in cloned descendants", () => {
+ const nodes = [
+ block("conditional", "block_1", {
+ type: "conditional",
+ data: {
+ debuggable: true,
+ editable: true,
+ label: "block_1",
+ continueOnFailure: false,
+ model: null,
+ branches: [
+ {
+ id: "branch-a",
+ criteria: null,
+ description: null,
+ is_default: false,
+ next_block_label: null,
+ },
+ {
+ id: "branch-b",
+ criteria: null,
+ description: null,
+ is_default: true,
+ next_block_label: null,
+ },
+ ],
+ activeBranchId: "branch-a",
+ mergeLabel: null,
+ },
+ }),
+ block("active-child", "block_2", {
+ hidden: false,
+ parentId: "conditional",
+ data: {
+ debuggable: true,
+ editable: true,
+ label: "block_2",
+ continueOnFailure: false,
+ model: null,
+ conditionalBranchId: "branch-a",
+ conditionalLabel: "block_1",
+ conditionalMergeLabel: null,
+ conditionalNodeId: "conditional",
+ },
+ }),
+ block("inactive-child", "block_3", {
+ hidden: true,
+ parentId: "conditional",
+ data: {
+ debuggable: true,
+ editable: true,
+ label: "block_3",
+ continueOnFailure: false,
+ model: null,
+ conditionalBranchId: "branch-b",
+ conditionalLabel: "block_1",
+ conditionalMergeLabel: null,
+ conditionalNodeId: "conditional",
+ },
+ }),
+ block("after", "block_4"),
+ ];
+ const edges = [
+ edge("conditional-after", "conditional", "after"),
+ edge("conditional-active", "conditional", "active-child", {
+ hidden: false,
+ data: {
+ conditionalBranchId: "branch-a",
+ conditionalNodeId: "conditional",
+ },
+ }),
+ edge("conditional-inactive", "conditional", "inactive-child", {
+ hidden: true,
+ data: {
+ conditionalBranchId: "branch-b",
+ conditionalNodeId: "conditional",
+ },
+ }),
+ ];
+
+ const result = duplicateBlockBelow({
+ nodes,
+ edges,
+ nodeId: "conditional",
+ generateId: idGenerator(),
+ generateLabel: labelGenerator,
+ });
+
+ const clonedActive = result?.nodes.find((node) => node.id === "new-2");
+ const clonedInactive = result?.nodes.find((node) => node.id === "new-3");
+ const clonedInactiveEdge = result?.edges.find(
+ (edge) => edge.source === "new-1" && edge.target === "new-3",
+ );
+
+ expect(clonedActive?.hidden).toBe(false);
+ expect(clonedInactive?.hidden).toBe(true);
+ expect(clonedInactive?.data).toEqual(
+ expect.objectContaining({
+ conditionalBranchId: "new-5",
+ conditionalNodeId: "new-1",
+ }),
+ );
+ expect(clonedInactiveEdge).toEqual(
+ expect.objectContaining({
+ hidden: true,
+ data: expect.objectContaining({
+ conditionalBranchId: "new-5",
+ conditionalNodeId: "new-1",
+ }),
+ }),
+ );
+ });
+});
diff --git a/skyvern-frontend/src/routes/workflows/editor/workflowDuplicate.ts b/skyvern-frontend/src/routes/workflows/editor/workflowDuplicate.ts
new file mode 100644
index 000000000..a96a6a862
--- /dev/null
+++ b/skyvern-frontend/src/routes/workflows/editor/workflowDuplicate.ts
@@ -0,0 +1,457 @@
+import type { Edge } from "@xyflow/react";
+
+import type { BranchContext } from "@/store/WorkflowPanelStore";
+
+import { replaceJinjaReference } from "./jinjaReferences";
+import type { AppNode, WorkflowBlockNode } from "./nodes";
+import type { NodeBaseData } from "./nodes/types";
+import { shouldKeepExistingEdgeForInsertion } from "./workflowInsertion";
+
+type GenerateId = () => string;
+type GenerateLabel = (existingLabels: Array) => string;
+
+type DuplicateBlockBelowOptions = {
+ nodes: Array;
+ edges: Array;
+ nodeId: string;
+ generateId: GenerateId;
+ generateLabel: GenerateLabel;
+};
+
+type DuplicateBlockBelowResult = {
+ nodes: Array;
+ edges: Array;
+ duplicatedNodeId: string;
+ duplicatedLabel: string;
+ position: number;
+};
+
+type BranchConditionLike = {
+ id: string;
+};
+
+type ConditionalDataLike = NodeBaseData & {
+ activeBranchId: string | null;
+ branches: Array;
+ mergeLabel?: string | null;
+};
+
+const SKIP_KEYS_FOR_REFERENCE_REWRITE = new Set([
+ "id",
+ "key",
+ "label",
+ "nodeId",
+ "parameterKeys",
+ "type",
+]);
+
+function clonePlain(value: T): T {
+ return JSON.parse(JSON.stringify(value)) as T;
+}
+
+function isWorkflowBlockNodeLike(node: AppNode): node is WorkflowBlockNode {
+ return node.type !== "nodeAdder" && node.type !== "start";
+}
+
+function outputKey(label: string): string {
+ return `${label}_output`;
+}
+
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+function branchKey(conditionalNodeId: string, branchId: string): string {
+ return `${conditionalNodeId}:${branchId}`;
+}
+
+function hasAncestor(
+ nodesById: Map,
+ node: AppNode,
+ ancestorId: string,
+): boolean {
+ let currentParentId = node.parentId;
+ const visited = new Set();
+
+ while (currentParentId && !visited.has(currentParentId)) {
+ if (currentParentId === ancestorId) {
+ return true;
+ }
+ visited.add(currentParentId);
+ currentParentId = nodesById.get(currentParentId)?.parentId;
+ }
+
+ return false;
+}
+
+function rewriteStringReferences(
+ value: string,
+ outputKeyMap: Map,
+): string {
+ let next = value;
+ outputKeyMap.forEach((newKey, oldKey) => {
+ next = replaceJinjaReference(next, oldKey, newKey);
+ // Code blocks receive upstream outputs as plain identifiers, while some
+ // serialized config stores the same output keys inside quoted strings.
+ next = next.replace(
+ new RegExp(
+ `(^|[^A-Za-z0-9_])${escapeRegExp(oldKey)}(?=$|[^A-Za-z0-9_])`,
+ "g",
+ ),
+ (_match, prefix: string) => `${prefix}${newKey}`,
+ );
+ });
+ return next;
+}
+
+function rewriteReferencesInValue(
+ value: T,
+ outputKeyMap: Map,
+): T {
+ if (typeof value === "string") {
+ return rewriteStringReferences(value, outputKeyMap) as T;
+ }
+
+ if (value === null || value === undefined) {
+ return value;
+ }
+
+ if (Array.isArray(value)) {
+ return value.map((item) =>
+ rewriteReferencesInValue(item, outputKeyMap),
+ ) as T;
+ }
+
+ if (typeof value !== "object") {
+ return value;
+ }
+
+ const result: Record = {};
+ Object.entries(value).forEach(([entryKey, entryValue]) => {
+ result[entryKey] = SKIP_KEYS_FOR_REFERENCE_REWRITE.has(entryKey)
+ ? entryValue
+ : rewriteReferencesInValue(entryValue, outputKeyMap);
+ });
+
+ return result as T;
+}
+
+function rewriteOutputReferencesInData(
+ data: T,
+ outputKeyMap: Map,
+): T {
+ const rewritten = rewriteReferencesInValue(data, outputKeyMap) as T;
+ const mutable = rewritten as T & {
+ loopVariableReference?: string;
+ parameterKeys?: Array | null;
+ };
+
+ if (Array.isArray(mutable.parameterKeys)) {
+ mutable.parameterKeys = mutable.parameterKeys.map(
+ (key) => outputKeyMap.get(key) ?? key,
+ );
+ }
+
+ if (mutable.loopVariableReference) {
+ mutable.loopVariableReference =
+ outputKeyMap.get(mutable.loopVariableReference) ??
+ mutable.loopVariableReference;
+ }
+
+ return mutable;
+}
+
+function isConditionalData(data: NodeBaseData): data is ConditionalDataLike {
+ const candidate = data as Partial;
+ return Array.isArray(candidate.branches);
+}
+
+function getBranchContextForNode(
+ node: WorkflowBlockNode,
+): BranchContext | undefined {
+ const data = node.data;
+ if (!data.conditionalNodeId || !data.conditionalBranchId) {
+ return undefined;
+ }
+
+ return {
+ conditionalNodeId: data.conditionalNodeId,
+ conditionalLabel: data.conditionalLabel ?? data.conditionalNodeId,
+ branchId: data.conditionalBranchId,
+ mergeLabel: data.conditionalMergeLabel ?? null,
+ };
+}
+
+function cloneEdgeData(
+ data: Edge["data"],
+ idMap: Map,
+ branchIdMap: Map,
+): Edge["data"] {
+ if (!data || typeof data !== "object") {
+ return data;
+ }
+
+ const cloned = clonePlain(data) as Record;
+ const conditionalNodeId = cloned.conditionalNodeId;
+ const conditionalBranchId = cloned.conditionalBranchId;
+
+ if (typeof conditionalNodeId === "string" && idMap.has(conditionalNodeId)) {
+ cloned.conditionalNodeId = idMap.get(conditionalNodeId);
+
+ if (typeof conditionalBranchId === "string") {
+ cloned.conditionalBranchId =
+ branchIdMap.get(branchKey(conditionalNodeId, conditionalBranchId)) ??
+ conditionalBranchId;
+ }
+ }
+
+ return cloned;
+}
+
+function makeEdge({
+ branch,
+ generateId,
+ source,
+ target,
+ type,
+}: {
+ branch?: BranchContext;
+ generateId: GenerateId;
+ source: string;
+ target: string;
+ type: string;
+}): Edge {
+ return {
+ id: generateId(),
+ type,
+ source,
+ target,
+ style: { strokeWidth: 2 },
+ data: branch
+ ? {
+ conditionalBranchId: branch.branchId,
+ conditionalNodeId: branch.conditionalNodeId,
+ }
+ : undefined,
+ };
+}
+
+function findInsertionEdge(
+ node: WorkflowBlockNode,
+ edges: Array,
+ subtreeIds: Set,
+): Edge | undefined {
+ const outgoingEdges = edges.filter((edge) => edge.source === node.id);
+ if (outgoingEdges.length === 0) {
+ return undefined;
+ }
+
+ const branch = getBranchContextForNode(node);
+ if (branch) {
+ const branchEdge = outgoingEdges.find((edge) => {
+ const edgeData = edge.data as
+ | { conditionalBranchId?: string; conditionalNodeId?: string }
+ | undefined;
+ return (
+ edgeData?.conditionalBranchId === branch.branchId &&
+ edgeData?.conditionalNodeId === branch.conditionalNodeId
+ );
+ });
+ if (branchEdge) {
+ return branchEdge;
+ }
+ }
+
+ return (
+ outgoingEdges.find((edge) => !subtreeIds.has(edge.target)) ??
+ outgoingEdges[0]
+ );
+}
+
+export function duplicateBlockBelow({
+ nodes,
+ edges,
+ nodeId,
+ generateId,
+ generateLabel,
+}: DuplicateBlockBelowOptions): DuplicateBlockBelowResult | null {
+ const sourceNode = nodes.find((node) => node.id === nodeId);
+ if (!sourceNode || !isWorkflowBlockNodeLike(sourceNode)) {
+ return null;
+ }
+
+ const nodesById = new Map(nodes.map((node) => [node.id, node]));
+ const subtreeNodes = nodes.filter(
+ (node) => node.id === nodeId || hasAncestor(nodesById, node, nodeId),
+ );
+ const subtreeIds = new Set(subtreeNodes.map((node) => node.id));
+ const idMap = new Map();
+ subtreeNodes.forEach((node) => {
+ idMap.set(node.id, generateId());
+ });
+
+ const existingLabels = nodes
+ .filter(isWorkflowBlockNodeLike)
+ .map((node) => node.data.label);
+ const labelMap = new Map();
+ subtreeNodes.filter(isWorkflowBlockNodeLike).forEach((node) => {
+ const newLabel = generateLabel(existingLabels);
+ existingLabels.push(newLabel);
+ labelMap.set(node.data.label, newLabel);
+ });
+
+ const outputKeyMap = new Map();
+ labelMap.forEach((newLabel, oldLabel) => {
+ outputKeyMap.set(outputKey(oldLabel), outputKey(newLabel));
+ });
+
+ const branchIdMap = new Map();
+ subtreeNodes.filter(isWorkflowBlockNodeLike).forEach((node) => {
+ if (!isConditionalData(node.data)) {
+ return;
+ }
+ node.data.branches.forEach((branch) => {
+ branchIdMap.set(branchKey(node.id, branch.id), generateId());
+ });
+ });
+
+ const clonedNodes = subtreeNodes.map((node): AppNode => {
+ const cloned = clonePlain(node);
+ cloned.id = idMap.get(node.id)!;
+ cloned.position = { x: 0, y: 0 };
+ cloned.selected = false;
+
+ if (node.parentId) {
+ cloned.parentId = idMap.get(node.parentId) ?? node.parentId;
+ }
+
+ if (!isWorkflowBlockNodeLike(node) || !isWorkflowBlockNodeLike(cloned)) {
+ return cloned;
+ }
+
+ const clonedData = rewriteOutputReferencesInData(
+ clonePlain(node.data),
+ outputKeyMap,
+ );
+
+ clonedData.label = labelMap.get(node.data.label)!;
+
+ if (
+ clonedData.conditionalNodeId &&
+ idMap.has(clonedData.conditionalNodeId)
+ ) {
+ const oldConditionalNodeId = clonedData.conditionalNodeId;
+ const oldConditionalNode = nodesById.get(oldConditionalNodeId);
+ clonedData.conditionalNodeId = idMap.get(oldConditionalNodeId)!;
+ if (oldConditionalNode && isWorkflowBlockNodeLike(oldConditionalNode)) {
+ clonedData.conditionalLabel =
+ labelMap.get(oldConditionalNode.data.label) ??
+ clonedData.conditionalLabel;
+ }
+ if (clonedData.conditionalBranchId) {
+ clonedData.conditionalBranchId =
+ branchIdMap.get(
+ branchKey(oldConditionalNodeId, clonedData.conditionalBranchId),
+ ) ?? clonedData.conditionalBranchId;
+ }
+ }
+
+ if (
+ clonedData.conditionalMergeLabel &&
+ labelMap.has(clonedData.conditionalMergeLabel)
+ ) {
+ clonedData.conditionalMergeLabel = labelMap.get(
+ clonedData.conditionalMergeLabel,
+ )!;
+ }
+
+ if (isConditionalData(clonedData)) {
+ const oldActiveBranchId = clonedData.activeBranchId;
+ clonedData.branches = clonedData.branches.map((branch) => ({
+ ...branch,
+ id: branchIdMap.get(branchKey(node.id, branch.id)) ?? branch.id,
+ }));
+ clonedData.activeBranchId = oldActiveBranchId
+ ? (branchIdMap.get(branchKey(node.id, oldActiveBranchId)) ??
+ oldActiveBranchId)
+ : oldActiveBranchId;
+ if (clonedData.mergeLabel && labelMap.has(clonedData.mergeLabel)) {
+ clonedData.mergeLabel = labelMap.get(clonedData.mergeLabel)!;
+ }
+ }
+
+ cloned.data = clonedData;
+ return cloned;
+ });
+
+ const internalEdges = edges
+ .filter(
+ (edge) => subtreeIds.has(edge.source) && subtreeIds.has(edge.target),
+ )
+ .map((edge): Edge => {
+ return {
+ ...clonePlain(edge),
+ id: generateId(),
+ source: idMap.get(edge.source)!,
+ target: idMap.get(edge.target)!,
+ data: cloneEdgeData(edge.data, idMap, branchIdMap),
+ };
+ });
+
+ const insertionEdge = findInsertionEdge(sourceNode, edges, subtreeIds);
+ const next = insertionEdge?.target ?? null;
+ const branch = getBranchContextForNode(sourceNode);
+ const editedEdges = edges.filter((edge) =>
+ shouldKeepExistingEdgeForInsertion(edge, {
+ branch,
+ next,
+ previous: sourceNode.id,
+ }),
+ );
+
+ const duplicatedNodeId = idMap.get(sourceNode.id)!;
+ const insertionEdges = [
+ makeEdge({
+ branch,
+ generateId,
+ source: sourceNode.id,
+ target: duplicatedNodeId,
+ type: "edgeWithAddButton",
+ }),
+ ];
+
+ if (next) {
+ insertionEdges.push(
+ makeEdge({
+ branch,
+ generateId,
+ source: duplicatedNodeId,
+ target: next,
+ type: insertionEdge?.type ?? "default",
+ }),
+ );
+ }
+
+ const sourceSubtreeIndexes = subtreeNodes.map((node) => nodes.indexOf(node));
+ const insertIndex = Math.max(...sourceSubtreeIndexes) + 1;
+ const nextNodes = [
+ ...nodes.slice(0, insertIndex),
+ ...clonedNodes,
+ ...nodes.slice(insertIndex),
+ ];
+
+ return {
+ nodes: nextNodes,
+ edges: [
+ ...editedEdges.filter((edge) => edge.source !== sourceNode.id),
+ // Keep the inserted bridge as the first source-owned outgoing edge;
+ // branch-specific source edges are preserved immediately after it.
+ ...insertionEdges,
+ ...editedEdges.filter((edge) => edge.source === sourceNode.id),
+ ...internalEdges,
+ ],
+ duplicatedNodeId,
+ duplicatedLabel: labelMap.get(sourceNode.data.label)!,
+ position: insertIndex,
+ };
+}
diff --git a/skyvern-frontend/src/routes/workflows/hooks/useDuplicateNodeCallback.ts b/skyvern-frontend/src/routes/workflows/hooks/useDuplicateNodeCallback.ts
new file mode 100644
index 000000000..533928261
--- /dev/null
+++ b/skyvern-frontend/src/routes/workflows/hooks/useDuplicateNodeCallback.ts
@@ -0,0 +1,8 @@
+import { BlockActionContext } from "@/store/BlockActionContext";
+import { useContext } from "react";
+
+function useDuplicateNodeCallback() {
+ return useContext(BlockActionContext)?.duplicateNodeCallback;
+}
+
+export { useDuplicateNodeCallback };
diff --git a/skyvern-frontend/src/store/BlockActionContext.ts b/skyvern-frontend/src/store/BlockActionContext.ts
index f5b077331..0e449b4fa 100644
--- a/skyvern-frontend/src/store/BlockActionContext.ts
+++ b/skyvern-frontend/src/store/BlockActionContext.ts
@@ -1,6 +1,7 @@
import { createContext } from "react";
type RequestDeleteNodeCallback = (id: string, label: string) => void;
+type DuplicateNodeCallback = (id: string) => void;
type TransmuteNodeCallback = (id: string, nodeName: string) => void;
type ToggleScriptForNodeCallback = (opts: {
id?: string;
@@ -11,6 +12,7 @@ type ToggleScriptForNodeCallback = (opts: {
const BlockActionContext = createContext<
| {
requestDeleteNodeCallback: RequestDeleteNodeCallback;
+ duplicateNodeCallback: DuplicateNodeCallback;
transmuteNodeCallback: TransmuteNodeCallback;
toggleScriptForNodeCallback?: ToggleScriptForNodeCallback;
}