diff --git a/packages/frontend/editor-ui/src/composables/useCanvasOperations.test.ts b/packages/frontend/editor-ui/src/composables/useCanvasOperations.test.ts index 4bf697c71c..b09a125f7d 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasOperations.test.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasOperations.test.ts @@ -388,6 +388,144 @@ describe('useCanvasOperations', () => { expect(position).toEqual([0, 0]); }); + + it('should apply custom Y offset for AI Language Model connection type', () => { + const uiStore = mockedStore(useUIStore); + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); + + const node = createTestNode({ id: '0' }); + const nodeTypeDescription = mockNodeTypeDescription(); + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + + uiStore.lastInteractedWithNode = createTestNode({ + position: [100, 100], + type: 'test', + typeVersion: 1, + }); + uiStore.lastInteractedWithNodeHandle = `outputs/${NodeConnectionTypes.AiLanguageModel}/0`; + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + workflowObject.getNode = vi.fn().mockReturnValue(node); + + vi.spyOn(NodeHelpers, 'getNodeOutputs').mockReturnValueOnce([ + { type: NodeConnectionTypes.AiLanguageModel }, + ]); + vi.spyOn(NodeHelpers, 'getConnectionTypes') + .mockReturnValueOnce([]) + .mockReturnValueOnce([]) + .mockReturnValueOnce([NodeConnectionTypes.AiLanguageModel]); + + const { resolveNodePosition } = useCanvasOperations(); + const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription); + + // Configuration node size is [200, 80], so customOffset = 200 * 2 = 400 + // Expected position: [100 + (200/1) * 1 - 200/2 - 400, 100 + 220] = [-200, 320] + expect(position[0]).toBeLessThan(100); // Node should be moved left due to custom offset + expect(position[1]).toEqual(320); // Standard Y position for configuration nodes + }); + + it('should apply custom Y offset for AI Memory connection type', () => { + const uiStore = mockedStore(useUIStore); + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); + + const node = createTestNode({ id: '0' }); + const nodeTypeDescription = mockNodeTypeDescription(); + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + + uiStore.lastInteractedWithNode = createTestNode({ + position: [100, 100], + type: 'test', + typeVersion: 1, + }); + uiStore.lastInteractedWithNodeHandle = `outputs/${NodeConnectionTypes.AiMemory}/0`; + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + workflowObject.getNode = vi.fn().mockReturnValue(node); + + vi.spyOn(NodeHelpers, 'getNodeOutputs').mockReturnValueOnce([ + { type: NodeConnectionTypes.AiMemory }, + ]); + vi.spyOn(NodeHelpers, 'getConnectionTypes') + .mockReturnValueOnce([]) + .mockReturnValueOnce([]) + .mockReturnValueOnce([NodeConnectionTypes.AiMemory]); + + const { resolveNodePosition } = useCanvasOperations(); + const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription); + + expect(position[0]).toBeLessThan(100); // Node should be moved left due to custom offset + expect(position[1]).toEqual(320); // Standard Y position for configuration nodes + }); + + it('should not apply custom offset for regular connection types', () => { + const uiStore = mockedStore(useUIStore); + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); + + const node = createTestNode({ id: '0' }); + const nodeTypeDescription = mockNodeTypeDescription(); + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + + uiStore.lastInteractedWithNode = createTestNode({ + position: [100, 100], + type: 'test', + typeVersion: 1, + }); + uiStore.lastInteractedWithNodeHandle = `outputs/${NodeConnectionTypes.AiTool}/0`; + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + workflowObject.getNode = vi.fn().mockReturnValue(node); + + vi.spyOn(NodeHelpers, 'getNodeOutputs').mockReturnValueOnce([ + { type: NodeConnectionTypes.AiTool }, + ]); + vi.spyOn(NodeHelpers, 'getConnectionTypes') + .mockReturnValueOnce([]) + .mockReturnValueOnce([]) + .mockReturnValueOnce([NodeConnectionTypes.AiTool]); + + const { resolveNodePosition } = useCanvasOperations(); + const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription); + + // No custom offset applied + expect(position).toEqual([60, 320]); + }); + + it('should handle missing connection type gracefully', () => { + const uiStore = mockedStore(useUIStore); + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); + + const node = createTestNode({ id: '0' }); + const nodeTypeDescription = mockNodeTypeDescription(); + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + + uiStore.lastInteractedWithNode = createTestNode({ + position: [100, 100], + type: 'test', + typeVersion: 1, + }); + // No lastInteractedWithNodeHandle set + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + workflowObject.getNode = vi.fn().mockReturnValue(node); + + vi.spyOn(NodeHelpers, 'getNodeOutputs').mockReturnValueOnce([ + { type: NodeConnectionTypes.AiTool }, + ]); + vi.spyOn(NodeHelpers, 'getConnectionTypes') + .mockReturnValueOnce([]) + .mockReturnValueOnce([]) + .mockReturnValueOnce([NodeConnectionTypes.AiTool]); + + const { resolveNodePosition } = useCanvasOperations(); + const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription); + + // No custom offset applied + expect(position).toEqual([60, 320]); + }); }); describe('updateNodesPosition', () => { diff --git a/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts b/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts index b62d706d18..3e3072514b 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts @@ -1029,7 +1029,7 @@ export function useCanvasOperations() { const nodeSize = connectionType === NodeConnectionTypes.Main ? DEFAULT_NODE_SIZE : CONFIGURATION_NODE_SIZE; - let pushOffsets: XYPosition = [nodeSize[0] / 2, nodeSize[1] / 2]; + const pushOffsets: XYPosition = [nodeSize[0] / 2, nodeSize[1] / 2]; let position: XYPosition | undefined = node.position; if (position) { @@ -1130,7 +1130,17 @@ export function useCanvasOperations() { } catch (e) {} const outputTypes = NodeHelpers.getConnectionTypes(outputs); - pushOffsets = [100, 0]; + /** + * Custom Y offsets for specific connection types when adding them using the plus button: + * - AI Language Model: Moved left by 2 node widths + * - AI Memory: Moved left by 1 node width + */ + const CUSTOM_Y_OFFSETS: Record = { + [NodeConnectionTypes.AiLanguageModel]: nodeSize[0] * 2, + [NodeConnectionTypes.AiMemory]: nodeSize[0], + }; + + const customOffset: number = CUSTOM_Y_OFFSETS[connectionType as string] ?? 0; if ( outputTypes.length > 0 && @@ -1152,7 +1162,8 @@ export function useCanvasOperations() { lastInteractedWithNode.position[0] + (CONFIGURABLE_NODE_SIZE[0] / lastInteractedWithNodeWidthDivisions) * (scopedConnectionIndex + 1) - - nodeSize[0] / 2, + nodeSize[0] / 2 - + customOffset, lastInteractedWithNode.position[1] + PUSH_NODES_OFFSET, ]; } else {