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