diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/CustomInstructionsChatLoadScopeTest.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/CustomInstructionsChatLoadScopeTest.java
new file mode 100644
index 00000000..aee51a1f
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/CustomInstructionsChatLoadScopeTest.java
@@ -0,0 +1,35 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+package com.microsoft.copilot.eclipse.core;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.NullSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import com.microsoft.copilot.eclipse.core.chat.CustomInstructionsChatLoadScope;
+
+class CustomInstructionsChatLoadScopeTest {
+
+ @ParameterizedTest
+ @EnumSource(CustomInstructionsChatLoadScope.class)
+ void testStringToEnumEntryConversion(CustomInstructionsChatLoadScope enumEntry) {
+ String inputValue = enumEntry.getValue();
+
+ CustomInstructionsChatLoadScope actualResult = CustomInstructionsChatLoadScope.fromValue(inputValue);
+
+ assertEquals(enumEntry, actualResult);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "wrongValue" })
+ @NullSource
+ void testStringToEnumEntryConversionThrowsExceptionForWrongValues(String value) {
+ assertThrows(IllegalArgumentException.class, () -> CustomInstructionsChatLoadScope.fromValue(value));
+ }
+
+}
diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java
index 7e2ae6ca..3a451cf7 100644
--- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java
+++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java
@@ -37,6 +37,9 @@ private Constants() {
public static final String CUSTOM_INSTRUCTIONS_WORKSPACE = "customInstructionsWorkspace";
public static final String CUSTOM_INSTRUCTIONS_WORKSPACE_ENABLED = "customInstructionsWorkspaceEnabled";
public static final String CUSTOM_INSTRUCTIONS_GIT_COMMIT = "customInstructionsGitCommit";
+ public static final String CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE = "customInstructionsChatLoadScope";
+ public static final String CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE_ALL = "allProjects";
+ public static final String CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE_REFERENCED = "referencedProjects";
public static final String GITHUB_COPILOT_URL = "http://github.com";
@Deprecated
public static final String QUICK_START_VERSION = "quickStartVersion";
diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/CustomInstructionsChatLoadScope.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/CustomInstructionsChatLoadScope.java
new file mode 100644
index 00000000..4ca1cff3
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/CustomInstructionsChatLoadScope.java
@@ -0,0 +1,54 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+package com.microsoft.copilot.eclipse.core.chat;
+
+import com.microsoft.copilot.eclipse.core.Constants;
+
+/**
+ * Scope loading modes for custom instructions in GitHub Copilot chat.
+ *
+ * - ALL_PROJECTS: Load custom instructions from all projects in the Eclipse workspace
+ *
- REFERENCED_PROJECTS: Load custom instructions only from parent projects of files/folders referenced in the
+ * Copilot chat
+ *
+ */
+public enum CustomInstructionsChatLoadScope {
+ ALL_PROJECTS(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE_ALL),
+ REFERENCED_PROJECTS(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE_REFERENCED);
+
+ public static final CustomInstructionsChatLoadScope DEFAULT_VALUE = CustomInstructionsChatLoadScope.ALL_PROJECTS;
+
+ private final String value;
+
+ CustomInstructionsChatLoadScope(String value) {
+ this.value = value;
+ }
+
+ /**
+ * Returns the string value representing this enum entry for preference serialization.
+ *
+ * @return the string value for this preference setting
+ */
+ public String getValue() {
+ return value;
+ }
+
+ /**
+ * Retrieves the enum constant corresponding to the given string value if available, otherwise an
+ * {@link IllegalArgumentException} is thrown.
+ *
+ * @param value the string value (preference) representing an enum entry
+ * @return the enum entry representing the given value
+ * @throws IllegalArgumentException if the value does not correspond to any enum entry
+ */
+ public static CustomInstructionsChatLoadScope fromValue(String value) {
+ for (CustomInstructionsChatLoadScope scope : values()) {
+ if (scope.getValue().equals(value)) {
+ return scope;
+ }
+ }
+ throw new IllegalArgumentException("Unknown CustomInstructionsLoadScope value: " + value);
+ }
+
+}
diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java
index 8aaa7f6d..a7b3883e 100644
--- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java
+++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java
@@ -20,6 +20,7 @@
import org.eclipse.lsp4j.ProgressParams;
import org.eclipse.lsp4j.Range;
import org.eclipse.lsp4j.TextDocumentIdentifier;
+import org.eclipse.lsp4j.WorkspaceFolder;
import org.eclipse.lsp4j.jsonrpc.Endpoint;
import org.eclipse.lsp4j.jsonrpc.messages.Either;
import org.eclipse.lsp4j.services.LanguageServer;
@@ -275,6 +276,19 @@ public CompletableFuture createConversation(String workDoneTok
List files, IFile currentFile, Range currentSelection, List turns, CopilotModel activeModel,
String chatModeName, String customChatModeId, List todos, String agentSlug,
String agentJobWorkspaceFolder) {
+ return createConversation(workDoneToken, message, files, currentFile, currentSelection, turns, activeModel,
+ chatModeName, customChatModeId, todos, agentSlug, agentJobWorkspaceFolder,
+ LSPEclipseUtils.getWorkspaceFolders());
+ }
+
+ /**
+ * Create a conversation with the given parameters, including optional workspace folders argument.
+ */
+ public CompletableFuture createConversation(String workDoneToken, String message,
+ List files, IFile currentFile, Range currentSelection, List turns, CopilotModel activeModel,
+ String chatModeName, String customChatModeId, List todos, String agentSlug,
+ String agentJobWorkspaceFolder, List workspaceFolders) {
+
boolean supportVision = activeModel.getCapabilities().supports().vision();
Either> messageWithImages = ChatMessageUtils
.createMessageWithImages(message, FileUtils.filterFilesFrom(files), supportVision);
@@ -288,7 +302,7 @@ public CompletableFuture createConversation(String workDoneTok
if (StringUtils.isBlank(agentSlug)) {
param.setWorkspaceFolder(PlatformUtils.getWorkspaceRootUri());
- param.setWorkspaceFolders(LSPEclipseUtils.getWorkspaceFolders());
+ param.setWorkspaceFolders(workspaceFolders);
param.setTodoList(todos);
} else {
// Set agentSlug if provided - this will modify the first turn's agentSlug
@@ -333,6 +347,19 @@ public CompletableFuture addConversationTurn(String workDoneToke
String message, List files, IFile currentFile, Range currentSelection, CopilotModel activeModel,
String chatModeName, String customChatModeId, List todoList, String agentSlug,
String agentJobWorkspaceFolder) {
+ return addConversationTurn(workDoneToken, conversationId, message, files, currentFile, currentSelection,
+ activeModel, chatModeName, customChatModeId, todoList, agentSlug, agentJobWorkspaceFolder,
+ LSPEclipseUtils.getWorkspaceFolders());
+ }
+
+ /**
+ * Create a conversation turn with the given parameters, including optional workspace folders argument.
+ */
+ public CompletableFuture addConversationTurn(String workDoneToken, String conversationId,
+ String message, List files, IFile currentFile, Range currentSelection, CopilotModel activeModel,
+ String chatModeName, String customChatModeId, List todoList, String agentSlug,
+ String agentJobWorkspaceFolder, List workspaceFolders) {
+
boolean supportVision = activeModel.getCapabilities().supports().vision();
Either> messageWithImages = ChatMessageUtils
.createMessageWithImages(message, FileUtils.filterFilesFrom(files), supportVision);
@@ -346,7 +373,7 @@ public CompletableFuture addConversationTurn(String workDoneToke
if (StringUtils.isBlank(agentSlug)) {
param.setWorkspaceFolder(PlatformUtils.getWorkspaceRootUri());
- param.setWorkspaceFolders(LSPEclipseUtils.getWorkspaceFolders());
+ param.setWorkspaceFolders(workspaceFolders);
param.setTodoList(todoList);
} else {
param.setAgentSlug(agentSlug);
diff --git a/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF
index bee9b6a8..6470013f 100644
--- a/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF
+++ b/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF
@@ -12,6 +12,7 @@ Require-Bundle: org.mockito.mockito-core;bundle-version="5.14.2",
org.eclipse.lsp4e;bundle-version="0.18.1",
org.eclipse.jdt.annotation;resolution:=optional,
junit-jupiter-api;bundle-version="5.10.1",
+ junit-jupiter-params;bundle-version="5.10.1",
org.mockito.junit-jupiter;bundle-version="5.10.2",
com.microsoft.copilot.eclipse.core;bundle-version="0.15.0",
com.microsoft.copilot.eclipse.ui;bundle-version="0.15.0",
diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtilsTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtilsTest.java
new file mode 100644
index 00000000..86831c04
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtilsTest.java
@@ -0,0 +1,89 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+package com.microsoft.copilot.eclipse.ui.utils;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.eclipse.jface.preference.IPreferenceStore;
+import org.eclipse.jface.preference.PreferenceStore;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+import com.microsoft.copilot.eclipse.core.Constants;
+import com.microsoft.copilot.eclipse.core.chat.CustomInstructionsChatLoadScope;
+
+class PreferencesUtilsTest {
+
+ private IPreferenceStore store;
+
+ @BeforeEach
+ void setUp() {
+ store = new PreferenceStore();
+ store.setDefault(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE,
+ CustomInstructionsChatLoadScope.DEFAULT_VALUE.getValue());
+ }
+
+ @AfterEach
+ void tearDown() {
+ store = null;
+ }
+
+ @ParameterizedTest
+ @EnumSource(CustomInstructionsChatLoadScope.class)
+ void testGetCustomInstructionsChatLoadScope_returnsStoredValue(CustomInstructionsChatLoadScope scope) {
+ store.setValue(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, scope.getValue());
+
+ CustomInstructionsChatLoadScope result = PreferencesUtils.getCustomInstructionsChatLoadScope(store);
+
+ assertEquals(scope, result);
+ }
+
+ @Test
+ void testGetCustomInstructionsChatLoadScope_fallsBackToDefaultInCaseOfInvalidValueInStore() {
+ store.setValue(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, "invalid_value");
+
+ CustomInstructionsChatLoadScope result = PreferencesUtils.getCustomInstructionsChatLoadScope(store);
+
+ assertEquals(CustomInstructionsChatLoadScope.DEFAULT_VALUE, result);
+ assertEquals(CustomInstructionsChatLoadScope.DEFAULT_VALUE.getValue(),
+ store.getString(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE),
+ "The invalid stored value should have been corrected to the default");
+ }
+
+ @Test
+ void testGetCustomInstructionsChatLoadScopeDefault_returnsConfiguredDefault() {
+ // explicitly store varying values for InstanceScope and DefaultScope
+ store.setValue(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE,
+ CustomInstructionsChatLoadScope.ALL_PROJECTS.getValue());
+ store.setDefault(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE,
+ CustomInstructionsChatLoadScope.REFERENCED_PROJECTS.getValue());
+
+ CustomInstructionsChatLoadScope result = PreferencesUtils.getCustomInstructionsChatLoadScopeDefault(store);
+
+ // Should return the default value, not the stored value
+ assertEquals(CustomInstructionsChatLoadScope.REFERENCED_PROJECTS, result);
+ }
+
+ @Test
+ void testGetCustomInstructionsChatLoadScopeDefault_fallsBackToValidDefaultInCaseOfInvalidValue() {
+ String invalidDefaultValue = "invalid_default_value";
+ String instanceScopValue = CustomInstructionsChatLoadScope.REFERENCED_PROJECTS.getValue();
+ store.setDefault(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, invalidDefaultValue);
+ store.setValue(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, instanceScopValue);
+
+ CustomInstructionsChatLoadScope result = PreferencesUtils.getCustomInstructionsChatLoadScopeDefault(store);
+
+ assertEquals(CustomInstructionsChatLoadScope.DEFAULT_VALUE, result);
+ assertEquals(invalidDefaultValue,
+ store.getDefaultString(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE),
+ "The stored DefaultScope value should not change, even if it's invalid. "
+ + "That is repsonsibility of a PreferenceInitializer.");
+ assertEquals(instanceScopValue,
+ store.getString(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE),
+ "The stored InstanceScope value should not change");
+ }
+}
diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtilsTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtilsTest.java
index 4194013e..2f4abb15 100644
--- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtilsTest.java
+++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtilsTest.java
@@ -7,10 +7,16 @@
import static org.mockito.Mockito.*;
import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Stream;
import org.eclipse.core.resources.IFile;
import org.eclipse.core.resources.IFolder;
+import org.eclipse.core.resources.IProject;
+import org.eclipse.core.resources.IResource;
import org.eclipse.jface.viewers.StructuredSelection;
+import org.eclipse.lsp4e.LSPEclipseUtils;
+import org.eclipse.lsp4j.WorkspaceFolder;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -18,6 +24,8 @@
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
import com.microsoft.copilot.eclipse.core.utils.FileUtils;
@@ -35,14 +43,30 @@ class ResourceUtilsTest {
@Mock
private IFolder mockFolder;
+
+ @Mock
+ private IProject mockProjectA;
+
+ @Mock
+ private IProject mockProjectB;
private MockedStatic mockedFileUtils;
+ private MockedStatic mockedLspUtils;
+
+ private static final String nameProjectA= "ProjectA";
+ private static final String nameProjectB= "ProjectB";
@BeforeEach
void setUp() {
mockedFileUtils = mockStatic(FileUtils.class);
mockedFileUtils.when(() -> FileUtils.isExcludedFromReferencedFiles(mockValidFile)).thenReturn(false);
mockedFileUtils.when(() -> FileUtils.isExcludedFromReferencedFiles(mockInvalidFile)).thenReturn(true);
+
+ mockedLspUtils = mockStatic(LSPEclipseUtils.class);
+ mockedLspUtils.when(() -> LSPEclipseUtils.toWorkspaceFolder(mockProjectA))
+ .thenReturn(new WorkspaceFolder("file:///" + nameProjectA, nameProjectA));
+ mockedLspUtils.when(() -> LSPEclipseUtils.toWorkspaceFolder(mockProjectB))
+ .thenReturn(new WorkspaceFolder("file:///" + nameProjectB, nameProjectB));
}
@AfterEach
@@ -50,6 +74,9 @@ void tearDown() {
if (mockedFileUtils != null) {
mockedFileUtils.close();
}
+ if (mockedLspUtils != null) {
+ mockedLspUtils.close();
+ }
}
@Test
@@ -82,4 +109,52 @@ void testCollectValidResourcesWithMocks() {
assertTrue(validResources.contains(mockFolder), "Should contain folder");
assertFalse(validResources.contains(mockInvalidFile), "Should not contain excluded file");
}
+
+ private static Stream> provideResourcesForNeverNullTest() {
+ return Stream.of(
+ null,
+ List.of(),
+ Arrays.asList((IResource) null),
+ List.of(mock(IFolder.class)),
+ List.of(mock(IProject.class))
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource("provideResourcesForNeverNullTest")
+ void testDeriveWorkspaceFoldersReturnsNeverNull(List resources) {
+ List result = ResourceUtils.deriveWorkspaceFoldersFrom(resources);
+
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void testDeriveWorkspaceFoldersWithMultipleResources() {
+ when(mockValidFile.getProject()).thenReturn(mockProjectA);
+ when(mockFolder.getProject()).thenReturn(mockProjectB);
+ when(mockProjectA.isAccessible()).thenReturn(true);
+ when(mockProjectB.isAccessible()).thenReturn(true);
+
+ List result = ResourceUtils.deriveWorkspaceFoldersFrom(List.of(mockValidFile, mockFolder));
+
+ assertNotNull(result);
+ assertFalse(result.isEmpty());
+ assertEquals(2, result.size(), "Both projects from both resources should be derived as workspace folders");
+ }
+
+ @Test
+ void testDeriveWorkspaceFoldersDoesNotReturnDuplicates() {
+ when(mockValidFile.getProject()).thenReturn(mockProjectA);
+ when(mockFolder.getProject()).thenReturn(mockProjectA);
+ when(mockProjectA.isAccessible()).thenReturn(true);
+ when(mockProjectA.getName()).thenReturn(nameProjectA);
+
+ List result = ResourceUtils.deriveWorkspaceFoldersFrom(List.of(mockValidFile, mockFolder));
+
+ assertNotNull(result);
+ assertFalse(result.isEmpty());
+ assertEquals(1, result.size(), "Projects in derived workspaces folders should be unique, no duplicates.");
+ assertEquals(mockProjectA.getName(), result.get(0).getName());
+ }
}
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java
index 02966b30..4ffe2ac9 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java
@@ -10,6 +10,7 @@
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
+import java.util.stream.Stream;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.core.resources.IFile;
@@ -21,7 +22,9 @@
import org.eclipse.e4.core.services.events.IEventBroker;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jface.preference.IPreferenceStore;
+import org.eclipse.lsp4e.LSPEclipseUtils;
import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.WorkspaceFolder;
import org.eclipse.swt.SWT;
import org.eclipse.swt.layout.GridData;
import org.eclipse.swt.layout.GridLayout;
@@ -44,6 +47,7 @@
import com.microsoft.copilot.eclipse.core.chat.ChatEventsManager;
import com.microsoft.copilot.eclipse.core.chat.ChatProgressListener;
import com.microsoft.copilot.eclipse.core.chat.CustomChatModeManager;
+import com.microsoft.copilot.eclipse.core.chat.CustomInstructionsChatLoadScope;
import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants;
import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection;
import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall;
@@ -86,6 +90,7 @@
import com.microsoft.copilot.eclipse.ui.chat.viewers.LoadingViewer;
import com.microsoft.copilot.eclipse.ui.chat.viewers.NoSubscriptionViewer;
import com.microsoft.copilot.eclipse.ui.swt.CssConstants;
+import com.microsoft.copilot.eclipse.ui.utils.ResourceUtils;
import com.microsoft.copilot.eclipse.ui.utils.SwtUtils;
/**
@@ -957,7 +962,7 @@ private void onSendInternal(String workDoneToken, String message, String agentSl
CompletableFuture addConversationFuture = ls.addConversationTurn(workDoneToken, conversationId,
processedMessage, references, currentFile, currentSelection, activeModel, chatModeName, customChatModeId,
- currentTodos, agentSlug, agentJobWorkspaceFolder);
+ currentTodos, agentSlug, agentJobWorkspaceFolder, deriveWorkspaceFolders(currentFile, references));
conversationFutures.add(addConversationFuture);
addConversationFuture.thenAccept(result -> {
@@ -1002,16 +1007,18 @@ private void onSendInternal(String workDoneToken, String message, String agentSl
chatModeName, customChatModeId, currentFile, references);
}
+ List workspaceFolders = deriveWorkspaceFolders(currentFile, references);
CompletableFuture createConversationFuture = null;
if (StringUtils.isBlank(agentSlug)) {
createConversationFuture = ls.createConversation(workDoneToken, processedMessage, references, currentFile,
- currentSelection, turns, activeModel, chatModeName, customChatModeId, todosToRestore, null, null);
+ currentSelection, turns, activeModel, chatModeName, customChatModeId, todosToRestore, null, null,
+ workspaceFolders);
} else {
// For conversations sending to agents, include agentSlug and specify the target agentJobWorkspaceFolder
// Don't send todo list for agent jobs - agents manage their own todo state independently
createConversationFuture = ls.createConversation(workDoneToken, processedMessage, references, currentFile,
currentSelection, turns, activeModel, chatModeName, customChatModeId, null, agentSlug,
- agentJobWorkspaceFolder);
+ agentJobWorkspaceFolder, workspaceFolders);
}
conversationFutures.add(createConversationFuture);
@@ -1051,6 +1058,27 @@ private void onSendInternal(String workDoneToken, String message, String agentSl
}
}
+ List deriveWorkspaceFolders(IFile currentFile, List references) {
+ String chatInstrScope = CopilotUi.getPlugin().getPreferenceStore().getString(
+ Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE);
+ CustomInstructionsChatLoadScope scope;
+ try {
+ scope = CustomInstructionsChatLoadScope.fromValue(chatInstrScope);
+ } catch (IllegalArgumentException e) {
+ CopilotCore.LOGGER.error(
+ "Failed parsing custom instructions load scope for chat preference, using default value", e);
+ scope = CustomInstructionsChatLoadScope.DEFAULT_VALUE;
+ }
+ return switch (scope) {
+ // take all projects from Eclipse workspace
+ case ALL_PROJECTS -> LSPEclipseUtils.getWorkspaceFolders();
+
+ // take only projects from selected files/folders
+ case REFERENCED_PROJECTS -> ResourceUtils.deriveWorkspaceFoldersFrom(
+ Stream.concat(references.stream(), Stream.of(currentFile)).toList());
+ };
+ }
+
/**
* Align with @Workspace of vscode, because we are actually indexing the whole workspace, not a single project.
* (@Project is only for IntelliJ.)
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java
index 0fe32ff0..43aaa6bb 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java
@@ -9,6 +9,7 @@
import org.eclipse.jface.preference.IPreferenceStore;
import com.microsoft.copilot.eclipse.core.Constants;
+import com.microsoft.copilot.eclipse.core.chat.CustomInstructionsChatLoadScope;
import com.microsoft.copilot.eclipse.ui.CopilotUi;
/**
@@ -31,6 +32,8 @@ public void initializeDefaultPreferences() {
pref.setDefault(Constants.AGENT_MAX_REQUESTS, 25);
pref.setDefault(Constants.CUSTOM_INSTRUCTIONS_WORKSPACE_ENABLED, false);
pref.setDefault(Constants.CUSTOM_INSTRUCTIONS_WORKSPACE, "");
+ pref.setDefault(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE,
+ CustomInstructionsChatLoadScope.DEFAULT_VALUE.getValue());
pref.setDefault(Constants.AUTO_BREAKPOINT_RESPONSE, false);
pref.setDefault(Constants.MCP, """
{
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomInstructionPreferencePage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomInstructionPreferencePage.java
index 137fff42..437d3aba 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomInstructionPreferencePage.java
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomInstructionPreferencePage.java
@@ -3,6 +3,8 @@
package com.microsoft.copilot.eclipse.ui.preferences;
+import java.util.Arrays;
+
import org.apache.commons.lang3.StringUtils;
import org.eclipse.core.resources.IFile;
import org.eclipse.core.resources.IProject;
@@ -15,6 +17,7 @@
import org.eclipse.jface.layout.GridDataFactory;
import org.eclipse.jface.preference.BooleanFieldEditor;
import org.eclipse.jface.preference.FieldEditorPreferencePage;
+import org.eclipse.jface.preference.IPreferenceStore;
import org.eclipse.jface.preference.StringFieldEditor;
import org.eclipse.jface.text.ITextViewer;
import org.eclipse.swt.SWT;
@@ -25,8 +28,10 @@
import org.eclipse.swt.layout.GridData;
import org.eclipse.swt.layout.GridLayout;
import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Combo;
import org.eclipse.swt.widgets.Composite;
import org.eclipse.swt.widgets.Group;
+import org.eclipse.swt.widgets.Label;
import org.eclipse.swt.widgets.Shell;
import org.eclipse.swt.widgets.Table;
import org.eclipse.swt.widgets.TableColumn;
@@ -41,7 +46,9 @@
import com.microsoft.copilot.eclipse.core.Constants;
import com.microsoft.copilot.eclipse.core.CopilotCore;
+import com.microsoft.copilot.eclipse.core.chat.CustomInstructionsChatLoadScope;
import com.microsoft.copilot.eclipse.ui.CopilotUi;
+import com.microsoft.copilot.eclipse.ui.utils.PreferencesUtils;
import com.microsoft.copilot.eclipse.ui.utils.SwtUtils;
import com.microsoft.copilot.eclipse.ui.utils.UiUtils;
@@ -54,11 +61,15 @@ public class CustomInstructionPreferencePage extends FieldEditorPreferencePage i
private BooleanFieldEditor enableWorkspaceInstrField;
private StringFieldEditor workspaceInstrField;
private StringFieldEditor gitCommitInstrField;
+ private Combo chatInstrLoadScopeCombo;
+
+ private static final CustomInstructionsChatLoadScope[] SCOPES = CustomInstructionsChatLoadScope.values();
// Variables to track initial preference values for change detection
private boolean initialWorkspaceEnabled;
private String initialWorkspaceInstructions;
private String initialGitCommitInstructions;
+ private CustomInstructionsChatLoadScope initialChatCustomInstrLoadScope;
private static final String GITHUB = ".github";
private static final String COPILOT_INSTRUCTIONS = "copilot-instructions.md";
@@ -106,6 +117,9 @@ private void initializePreferenceValues() {
initialWorkspaceEnabled = getPreferenceStore().getBoolean(Constants.CUSTOM_INSTRUCTIONS_WORKSPACE_ENABLED);
initialWorkspaceInstructions = getPreferenceStore().getString(Constants.CUSTOM_INSTRUCTIONS_WORKSPACE);
initialGitCommitInstructions = getPreferenceStore().getString(Constants.CUSTOM_INSTRUCTIONS_GIT_COMMIT);
+
+ initialChatCustomInstrLoadScope = PreferencesUtils.getCustomInstructionsChatLoadScope(getPreferenceStore());
+ updateChatInstrLoadScopeComboSelection(false);
}
/**
@@ -117,10 +131,12 @@ private boolean hasPreferencesChanged() {
boolean currentWorkspaceEnabled = enableWorkspaceInstrField.getBooleanValue();
String currentWorkspaceInstructions = workspaceInstrField.getStringValue();
String currentGitCommitInstructions = gitCommitInstrField.getStringValue();
+ CustomInstructionsChatLoadScope currentCustomInstrLoadScope = getSelectedCustomInstrLoadScope();
return currentWorkspaceEnabled != initialWorkspaceEnabled
|| !StringUtils.equals(currentWorkspaceInstructions, initialWorkspaceInstructions)
- || !StringUtils.equals(currentGitCommitInstructions, initialGitCommitInstructions);
+ || !StringUtils.equals(currentGitCommitInstructions, initialGitCommitInstructions)
+ || !initialChatCustomInstrLoadScope.equals(currentCustomInstrLoadScope);
}
@Override
@@ -130,10 +146,53 @@ public boolean performOk() {
initialWorkspaceInstructions = workspaceInstrField.getStringValue();
initialGitCommitInstructions = gitCommitInstrField.getStringValue();
+ initialChatCustomInstrLoadScope = getSelectedCustomInstrLoadScope();
+ getPreferenceStore().setValue(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE,
+ initialChatCustomInstrLoadScope.getValue());
+
// Call super to save preferences
return super.performOk();
}
+ @Override
+ protected void performDefaults() {
+ super.performDefaults();
+
+ updateChatInstrLoadScopeComboSelection(true);
+ }
+
+ private void updateChatInstrLoadScopeComboSelection(boolean useDefaultValue) {
+ IPreferenceStore store = getPreferenceStore();
+ if (chatInstrLoadScopeCombo == null || chatInstrLoadScopeCombo.isDisposed() || store == null) {
+ return;
+ }
+
+ CustomInstructionsChatLoadScope scope = useDefaultValue
+ ? PreferencesUtils.getCustomInstructionsChatLoadScopeDefault(store)
+ : PreferencesUtils.getCustomInstructionsChatLoadScope(store);
+
+ // we rely here on using the enum entry order that we use in our combobox using the SCOPES array
+ chatInstrLoadScopeCombo.select(scope.ordinal());
+ }
+
+ private CustomInstructionsChatLoadScope getSelectedCustomInstrLoadScope() {
+ int selectionIndex = chatInstrLoadScopeCombo.getSelectionIndex();
+
+ if (selectionIndex >= 0 && selectionIndex < SCOPES.length) {
+ return SCOPES[selectionIndex];
+ } else {
+ return CustomInstructionsChatLoadScope.DEFAULT_VALUE;
+ }
+ }
+
+ private static String getCustomInstrLoadScopeLabel(CustomInstructionsChatLoadScope scope) {
+ return switch (scope) {
+ case ALL_PROJECTS -> Messages.preferences_page_custom_instructions_chat_load_scope_all;
+ case REFERENCED_PROJECTS -> Messages.preferences_page_custom_instructions_chat_load_scope_referenced;
+ };
+ }
+
+
private void createWorkspaceInstructionsField(Composite parent, GridLayout gl) {
// workspace instructions group
Group workspaceInstrGroup = new Group(parent, SWT.NONE);
@@ -232,6 +291,26 @@ public void controlResized(org.eclipse.swt.events.ControlEvent e) {
// Add note using WrappableNoteLabel
new WrappableNoteLabel(projectInstrGroup, Messages.preferences_page_note_prefix + " ",
Messages.preferences_page_custom_instructions_project_table_note);
+
+ // add label and drop-down list for custom instructions loading scope options
+ Composite chatInstrContainer = new Composite(projectInstrGroup, SWT.NONE);
+ GridLayout containerLayout = new GridLayout(2, false);
+ containerLayout.marginWidth = 0;
+ containerLayout.marginHeight = 0;
+ chatInstrContainer.setLayout(containerLayout);
+ chatInstrContainer.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false));
+
+ Label label = new Label(chatInstrContainer, SWT.NONE);
+ label.setText(Messages.preferences_page_custom_instructions_chat_load_scope_label);
+ label.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false));
+
+ chatInstrLoadScopeCombo = new Combo(chatInstrContainer, SWT.DROP_DOWN | SWT.READ_ONLY);
+ String[] items = Arrays.stream(SCOPES)
+ .map(CustomInstructionPreferencePage::getCustomInstrLoadScopeLabel)
+ .toArray(String[]::new);
+ chatInstrLoadScopeCombo.setItems(items);
+ chatInstrLoadScopeCombo.setToolTipText(Messages.preferences_page_custom_instructions_chat_load_scope_combo_tooltip);
+ chatInstrLoadScopeCombo.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
}
private void createGitCommitInstructionsField(Composite parent, GridLayout gl) {
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java
index 72e62334..ab128386 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java
@@ -110,6 +110,10 @@ public class Messages extends NLS {
public static String preferences_page_custom_instructions_git_commit;
public static String preferences_page_custom_instructions_git_commit_desc;
public static String preferences_page_custom_instructions_git_commit_note;
+ public static String preferences_page_custom_instructions_chat_load_scope_label;
+ public static String preferences_page_custom_instructions_chat_load_scope_all;
+ public static String preferences_page_custom_instructions_chat_load_scope_referenced;
+ public static String preferences_page_custom_instructions_chat_load_scope_combo_tooltip;
public static String preferences_page_note_prefix;
public static String preferences_page_note_content;
public static String preferences_page_mcpOAuth_confirmTitle;
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties
index 4c961f58..031a67cd 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties
@@ -93,6 +93,10 @@ preferences_page_custom_instructions_project_editDialog_button_close=Close Page
preferences_page_custom_instructions_project_editDialog_button_stay=Stay on Page
preferences_page_custom_instructions_project_file_save_reminder_title=Save Reminder
preferences_page_custom_instructions_project_file_save_reminder_desc=Remember to save the file to ensure the project instructions take effect.
+preferences_page_custom_instructions_chat_load_scope_label=Load custom instructions from
+preferences_page_custom_instructions_chat_load_scope_all=all projects in workspace
+preferences_page_custom_instructions_chat_load_scope_referenced=projects inferred from chat-attached files
+preferences_page_custom_instructions_chat_load_scope_combo_tooltip=Decide which of the custom instructions will be used in the Copilot chat.
preferences_page_watched_files= Enable workspace context (experimental)
preferences_page_custom_instructions_git_commit= Git Commit Instructions
preferences_page_custom_instructions_git_commit_desc=Set custom instructions for Copilot Chat when generating commit messages.
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java
index efd3a7c8..779fdc8b 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java
@@ -3,6 +3,13 @@
package com.microsoft.copilot.eclipse.ui.utils;
+import org.eclipse.core.runtime.preferences.DefaultScope;
+import org.eclipse.core.runtime.preferences.InstanceScope;
+import org.eclipse.jface.preference.IPreferenceStore;
+
+import com.microsoft.copilot.eclipse.core.Constants;
+import com.microsoft.copilot.eclipse.core.CopilotCore;
+import com.microsoft.copilot.eclipse.core.chat.CustomInstructionsChatLoadScope;
import com.microsoft.copilot.eclipse.ui.preferences.ByokPreferencePage;
import com.microsoft.copilot.eclipse.ui.preferences.ChatPreferencesPage;
import com.microsoft.copilot.eclipse.ui.preferences.CompletionsPreferencesPage;
@@ -27,4 +34,45 @@ public static String[] getAllPreferenceIds() {
McpPreferencePage.ID, ByokPreferencePage.ID };
}
+ /**
+ * Returns the current value for the scope used for loading custom instructions in the chat.
+ *
+ * @param preferenceStore the preference store to read from
+ * @return the current setting from {@link InstanceScope}
+ */
+ public static CustomInstructionsChatLoadScope getCustomInstructionsChatLoadScope(IPreferenceStore preferenceStore) {
+ return getCustomInstructionsChatLoadScopeValue(preferenceStore, false);
+ }
+
+ /**
+ * Returns the default value for the scope used for loading custom instructions in the chat.
+ *
+ * @param preferenceStore the preference store to read from
+ * @return the current setting from {@link DefaultScope}
+ */
+ public static CustomInstructionsChatLoadScope getCustomInstructionsChatLoadScopeDefault(
+ IPreferenceStore preferenceStore) {
+ return getCustomInstructionsChatLoadScopeValue(preferenceStore, true);
+ }
+
+ private static CustomInstructionsChatLoadScope getCustomInstructionsChatLoadScopeValue(
+ IPreferenceStore preferenceStore, boolean readDefault) {
+
+ String value = readDefault ? preferenceStore.getDefaultString(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE)
+ : preferenceStore.getString(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE);
+
+ try {
+ return CustomInstructionsChatLoadScope.fromValue(value);
+ } catch (IllegalArgumentException e) {
+ CopilotCore.LOGGER.error("Failed to load custom instructions scope. Falling back to default value.", e);
+
+ if (!readDefault) {
+ // If the stored value is invalid, use the default value instead
+ preferenceStore.setValue(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE,
+ CustomInstructionsChatLoadScope.DEFAULT_VALUE.getValue());
+ }
+ return CustomInstructionsChatLoadScope.DEFAULT_VALUE;
+ }
+ }
+
}
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtils.java
index ddcaad6f..1532ee98 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtils.java
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtils.java
@@ -5,13 +5,17 @@
import java.util.ArrayList;
import java.util.List;
+import java.util.Objects;
import org.eclipse.core.resources.IFile;
import org.eclipse.core.resources.IFolder;
+import org.eclipse.core.resources.IProject;
import org.eclipse.core.resources.IResource;
import org.eclipse.core.runtime.IAdaptable;
import org.eclipse.core.runtime.Platform;
import org.eclipse.jface.viewers.IStructuredSelection;
+import org.eclipse.lsp4e.LSPEclipseUtils;
+import org.eclipse.lsp4j.WorkspaceFolder;
import com.microsoft.copilot.eclipse.core.utils.FileUtils;
@@ -73,6 +77,29 @@ public static SelectionStats analyzeSelection(IStructuredSelection selection) {
return new SelectionStats(fileCount, folderCount, invalidCount);
}
+ /**
+ * Derive workspace folders from the given list of resources by extracting their parent projects.
+ * Returns an unmodifiable list with distinct, accessible workspace folders.
+ * If the input list is null or empty, returns an empty list.
+ *
+ * @param resources list of resources from which their parent projects are used to derive the workspace folders.
+ * @return a never null list of workspace folders derived from the given resources.
+ */
+ public static List deriveWorkspaceFoldersFrom(List resources) {
+ if (resources == null || resources.isEmpty()) {
+ return List.of();
+ }
+
+ return resources.stream()
+ .filter(Objects::nonNull)
+ .map(IResource::getProject)
+ .filter(Objects::nonNull)
+ .distinct()
+ .filter(IProject::isAccessible)
+ .map(LSPEclipseUtils::toWorkspaceFolder)
+ .toList();
+ }
+
/**
* Adapt an object to IResource.
*/