Skip to content

Commit df41895

Browse files
authored
feat: Add pinned context support for Amazon Q chat (#473)
This PR implements pinned context functionality for Amazon Q Eclipse plugin, allowing users to pin files to their chat conversations for persistent context. This feature improves the relevance of Amazon Q responses by maintaining important context throughout the conversation. * Adds the ability to pin/unpin files in Amazon Q chat conversations * Implements automatic active editor tracking with 100ms debouncing * Enables the @pin Context feature visible in the chat UI * Provides LSP server integration for pinned context operations * Handles UTF8 rendering on Windows devices This change also remove the isUriInWorkspace check for when lsp sends showDocument notification to client. This check would gate the ability to open documents not present in the workspace which is required for paths associated with prompts/rules that are stored on disk and require opening it in the IDE for editing.
1 parent ee7b6b7 commit df41895

File tree

12 files changed

+275
-15
lines changed

12 files changed

+275
-15
lines changed

plugin/src/software/aws/toolkits/eclipse/amazonq/chat/ChatCommunicationManager.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,32 @@ public void sendMessageToChatServer(final Command command, final ChatMessage mes
258258
Activator.getLogger().error("Error processing mcpServerClick: " + e);
259259
}
260260
break;
261+
case LIST_RULES:
262+
try {
263+
Object listRulesResponse = amazonQLspServer.listRules(message.getData()).get();
264+
var listRulesCommand = ChatUIInboundCommand.createCommand(ChatUIInboundCommandName.ListRules.getValue(),
265+
listRulesResponse);
266+
Activator.getEventBroker().post(ChatUIInboundCommand.class, listRulesCommand);
267+
} catch (Exception e) {
268+
Activator.getLogger().error("Error processing listRules: " + e);
269+
}
270+
break;
271+
case RULE_CLICK:
272+
try {
273+
Object ruleClickResponse = amazonQLspServer.ruleClick(message.getData()).get();
274+
var ruleClickCommand = ChatUIInboundCommand.createCommand(ChatUIInboundCommandName.RuleClick.getValue(),
275+
ruleClickResponse);
276+
Activator.getEventBroker().post(ChatUIInboundCommand.class, ruleClickCommand);
277+
} catch (Exception e) {
278+
Activator.getLogger().error("Error processing ruleClick: " + e);
279+
}
280+
break;
281+
case PINNED_CONTEXT_ADD:
282+
amazonQLspServer.pinnedContextAdd(message.getData());
283+
break;
284+
case PINNED_CONTEXT_REMOVE:
285+
amazonQLspServer.pinnedContextRemove(message.getData());
286+
break;
261287
default:
262288
throw new AmazonQPluginException("Unexpected command received from Chat UI: " + command.toString());
263289
}

plugin/src/software/aws/toolkits/eclipse/amazonq/chat/models/ChatUIInboundCommandName.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ public enum ChatUIInboundCommandName {
1515
GenericCommand("genericCommand"),
1616
ChatOptionsUpdate("aws/chat/chatOptionsUpdate"),
1717
ListMcpServers("aws/chat/listMcpServers"),
18-
McpServerClick("aws/chat/mcpServerClick");
18+
McpServerClick("aws/chat/mcpServerClick"),
19+
ListRules("aws/chat/listRules"),
20+
RuleClick("aws/chat/ruleClick"),
21+
SendPinnedContext("aws/chat/sendPinnedContext");
1922

2023
private final String value;
2124

plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClient.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,6 @@ public interface AmazonQLspClient extends LanguageClient {
6161
@JsonNotification("aws/didCreateDirectory")
6262
void didCreateDirectory(Object params);
6363

64+
@JsonNotification("aws/chat/sendPinnedContext")
65+
void sendPinnedContext(Object params);
6466
}

plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClientImpl.java

Lines changed: 88 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import java.net.URI;
88
import java.net.URISyntaxException;
99
import java.net.URL;
10+
import java.nio.file.Paths;
1011
import java.util.ArrayList;
1112
import java.util.Arrays;
1213
import java.util.HashMap;
@@ -17,13 +18,16 @@
1718
import java.util.Optional;
1819
import java.util.UUID;
1920
import java.util.concurrent.CompletableFuture;
21+
import java.util.concurrent.atomic.AtomicReference;
2022

23+
import org.apache.commons.lang3.StringUtils;
2124
import org.eclipse.core.filesystem.EFS;
2225
import org.eclipse.core.filesystem.IFileStore;
2326
import org.eclipse.core.resources.IWorkspace;
2427
import org.eclipse.core.resources.ResourcesPlugin;
2528
import org.eclipse.core.runtime.CoreException;
2629
import org.eclipse.core.runtime.IPath;
30+
2731
import org.eclipse.core.runtime.NullProgressMonitor;
2832
import org.eclipse.core.runtime.Path;
2933
import org.eclipse.jface.action.IAction;
@@ -64,6 +68,7 @@
6468
import software.aws.toolkits.eclipse.amazonq.chat.ChatAsyncResultManager;
6569
import software.aws.toolkits.eclipse.amazonq.chat.ChatCommunicationManager;
6670
import software.aws.toolkits.eclipse.amazonq.chat.models.ChatUIInboundCommand;
71+
import software.aws.toolkits.eclipse.amazonq.chat.models.ChatUIInboundCommandName;
6772
import software.aws.toolkits.eclipse.amazonq.chat.models.GetSerializedChatParams;
6873
import software.aws.toolkits.eclipse.amazonq.chat.models.GetSerializedChatResult;
6974
import software.aws.toolkits.eclipse.amazonq.chat.models.SerializedChatResult;
@@ -82,6 +87,7 @@
8287
import software.aws.toolkits.eclipse.amazonq.lsp.model.SsoProfileData;
8388
import software.aws.toolkits.eclipse.amazonq.lsp.model.TelemetryEvent;
8489
import software.aws.toolkits.eclipse.amazonq.plugin.Activator;
90+
import software.aws.toolkits.eclipse.amazonq.util.QEclipseEditorUtils;
8591
import software.aws.toolkits.eclipse.amazonq.preferences.AmazonQPreferencePage;
8692
import software.aws.toolkits.eclipse.amazonq.telemetry.service.DefaultTelemetryService;
8793
import software.aws.toolkits.eclipse.amazonq.util.Constants;
@@ -223,15 +229,10 @@ public final CompletableFuture<ShowDocumentResult> showDocument(final ShowDocume
223229
} else {
224230
Display.getDefault().syncExec(() -> {
225231
try {
226-
if (!isUriInWorkspace(uri)) {
227-
Activator.getLogger().error("Attempted to open file outside workspace: " + uri);
228-
success[0] = false;
229-
} else {
230-
IWorkbenchPage page = PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage();
231-
IFileStore fileStore = EFS.getLocalFileSystem().getStore(new URI(uri));
232-
IDE.openEditorOnFileStore(page, fileStore);
233-
success[0] = true;
234-
}
232+
IWorkbenchPage page = PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage();
233+
IFileStore fileStore = EFS.getLocalFileSystem().getStore(new URI(uri));
234+
IDE.openEditorOnFileStore(page, fileStore);
235+
success[0] = true;
235236
} catch (Exception e) {
236237
Activator.getLogger().error("Error in UI thread while opening URI: " + uri, e);
237238
success[0] = false;
@@ -575,4 +576,82 @@ private boolean isUriInWorkspace(final String uri) {
575576
return false;
576577
}
577578
}
579+
580+
@Override
581+
public final void sendPinnedContext(final Object params) {
582+
Object updatedParams = params;
583+
Optional<String> fileUri = getActiveFileUri();
584+
if (fileUri.isPresent()) {
585+
Map<String, Object> textDocument = Map.of("uri", fileUri.get());
586+
if (params instanceof Map) {
587+
@SuppressWarnings("unchecked")
588+
Map<String, Object> paramsMap = new HashMap<>((Map<String, Object>) params);
589+
paramsMap.put("textDocument", textDocument);
590+
updatedParams = paramsMap;
591+
} else {
592+
updatedParams = Map.of("params", params, "textDocument", textDocument);
593+
}
594+
}
595+
596+
var sendPinnedContextCommand = ChatUIInboundCommand.createCommand(ChatUIInboundCommandName.SendPinnedContext.getValue(), updatedParams);
597+
Activator.getEventBroker().post(ChatUIInboundCommand.class, sendPinnedContextCommand);
598+
}
599+
600+
private Optional<String> getActiveFileUri() {
601+
AtomicReference<Optional<String>> fileUri = new AtomicReference<>();
602+
Display.getDefault().syncExec(() -> {
603+
try {
604+
fileUri.set(getActiveEditorRelativePath());
605+
} catch (Exception e) {
606+
Activator.getLogger().error("Error getting active file URI", e);
607+
fileUri.set(Optional.empty());
608+
}
609+
});
610+
return fileUri.get();
611+
}
612+
613+
private Optional<String> getActiveEditorRelativePath() {
614+
var activeEditor = QEclipseEditorUtils.getActiveTextEditor();
615+
if (activeEditor == null) {
616+
return Optional.empty();
617+
}
618+
return QEclipseEditorUtils.getOpenFileUri(activeEditor.getEditorInput())
619+
.map(this::getRelativePath);
620+
}
621+
622+
private String getRelativePath(final String absoluteUri) {
623+
try {
624+
if (StringUtils.isBlank(absoluteUri)) {
625+
return absoluteUri;
626+
}
627+
628+
var uri = new URI(absoluteUri);
629+
var activeFilePath = new File(uri).getCanonicalPath();
630+
631+
// Get workspace root path
632+
var workspace = ResourcesPlugin.getWorkspace();
633+
var workspacePath = workspace.getRoot().getLocation();
634+
if (workspacePath == null) {
635+
return activeFilePath;
636+
}
637+
638+
var workspaceRoot = workspacePath.toFile().getCanonicalPath();
639+
if (StringUtils.isBlank(workspaceRoot)) {
640+
return activeFilePath;
641+
}
642+
643+
if (StringUtils.startsWithIgnoreCase(activeFilePath, workspaceRoot)) {
644+
var workspaceRootPath = Paths.get(workspaceRoot);
645+
var activeFilePathObj = Paths.get(activeFilePath);
646+
var relativePath = workspaceRootPath.relativize(activeFilePathObj).normalize();
647+
return relativePath.toString().replace('\\', '/');
648+
}
649+
650+
// Not in workspace, return absolute path
651+
return activeFilePath;
652+
} catch (Exception e) {
653+
Activator.getLogger().error("Error occurred when attempting to determine relative path for: " + absoluteUri, e);
654+
return absoluteUri;
655+
}
656+
}
578657
}

plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServer.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,19 @@ <T extends Configuration> CompletableFuture<LspServerConfigurations<T>> getConfi
129129

130130
@JsonRequest("aws/chat/mcpServerClick")
131131
CompletableFuture<Object> mcpServerClick(Object params);
132+
133+
@JsonRequest("aws/chat/listRules")
134+
CompletableFuture<Object> listRules(Object params);
135+
136+
@JsonRequest("aws/chat/ruleClick")
137+
CompletableFuture<Object> ruleClick(Object params);
138+
139+
@JsonNotification("aws/chat/pinnedContextAdd")
140+
void pinnedContextAdd(Object params);
141+
142+
@JsonNotification("aws/chat/pinnedContextRemove")
143+
void pinnedContextRemove(Object params);
144+
145+
@JsonNotification("aws/chat/activeEditorChanged")
146+
void activeEditorChanged(Object params);
132147
}

plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServerBuilder.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ private Map<String, Object> getInitializationOptions(final ClientMetadata metada
5656
qOptions.put("developerProfiles", true);
5757
qOptions.put("customizationsWithMetadata", true);
5858
qOptions.put("mcp", true);
59+
qOptions.put("pinnedContextEnabled", true);
5960
qOptions.put("modelSelection", true);
6061
awsClientCapabilities.put("q", qOptions);
6162
Map<String, Object> window = new HashMap<>();
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package software.aws.toolkits.eclipse.amazonq.lsp.editor;
5+
6+
import java.util.HashMap;
7+
import java.util.Map;
8+
import java.util.Optional;
9+
import java.util.concurrent.ScheduledFuture;
10+
11+
import org.eclipse.swt.widgets.Display;
12+
import org.eclipse.ui.IPartListener2;
13+
import org.eclipse.ui.IWorkbenchPartReference;
14+
import org.eclipse.ui.IWorkbenchWindow;
15+
import org.eclipse.ui.PlatformUI;
16+
import org.eclipse.ui.texteditor.ITextEditor;
17+
18+
import software.aws.toolkits.eclipse.amazonq.plugin.Activator;
19+
import software.aws.toolkits.eclipse.amazonq.util.QEclipseEditorUtils;
20+
import software.aws.toolkits.eclipse.amazonq.util.ThreadingUtils;
21+
22+
public final class ActiveEditorChangeListener implements IPartListener2 {
23+
private static ActiveEditorChangeListener instance;
24+
private static final long DEBOUNCE_DELAY_MS = 100L;
25+
private ScheduledFuture<?> debounceTask;
26+
private IWorkbenchWindow registeredWindow;
27+
28+
private ActiveEditorChangeListener() { }
29+
30+
public static ActiveEditorChangeListener getInstance() {
31+
if (instance == null) {
32+
instance = new ActiveEditorChangeListener();
33+
}
34+
return instance;
35+
}
36+
37+
public void initialize() {
38+
Display.getDefault().asyncExec(() -> {
39+
try {
40+
registeredWindow = PlatformUI.getWorkbench().getActiveWorkbenchWindow();
41+
if (registeredWindow != null) {
42+
registeredWindow.getPartService().addPartListener(this);
43+
}
44+
} catch (Exception e) {
45+
Activator.getLogger().error("Failed to initialize ActiveEditorChangeListener", e);
46+
}
47+
});
48+
}
49+
50+
public void stop() {
51+
if (debounceTask != null) {
52+
debounceTask.cancel(true);
53+
}
54+
try {
55+
if (registeredWindow != null) {
56+
registeredWindow.getPartService().removePartListener(this);
57+
}
58+
} catch (Exception e) {
59+
Activator.getLogger().error("Error stopping ActiveEditorChangeListener", e);
60+
}
61+
}
62+
63+
@Override
64+
public void partActivated(final IWorkbenchPartReference partRef) {
65+
if (partRef.getPart(false) instanceof ITextEditor) {
66+
ITextEditor editor = (ITextEditor) partRef.getPart(false);
67+
handleEditorChange(editor);
68+
}
69+
}
70+
71+
@Override
72+
public void partClosed(final IWorkbenchPartReference partRef) {
73+
if (partRef.getPart(false) instanceof ITextEditor) {
74+
handleEditorChange(null);
75+
}
76+
}
77+
78+
private void handleEditorChange(final ITextEditor editor) {
79+
// Cancel any pending notification
80+
if (debounceTask != null) {
81+
debounceTask.cancel(false);
82+
}
83+
84+
// Schedule a new notification after the debounce period
85+
debounceTask = (ScheduledFuture<?>) ThreadingUtils.scheduleAsyncTaskWithDelay(() -> {
86+
Display.getDefault().syncExec(() -> {
87+
try {
88+
Map<String, Object> params = createActiveEditorParams(editor);
89+
var lspServer = Activator.getLspProvider().getAmazonQServer().get();
90+
lspServer.activeEditorChanged(params);
91+
} catch (Exception e) {
92+
Activator.getLogger().error("Failed to send active editor changed notification", e);
93+
}
94+
});
95+
}, DEBOUNCE_DELAY_MS);
96+
}
97+
98+
private Map<String, Object> createActiveEditorParams(final ITextEditor editor) {
99+
Map<String, Object> params = new HashMap<>();
100+
if (editor != null) {
101+
Optional<String> fileUri = QEclipseEditorUtils.getOpenFileUri(editor.getEditorInput());
102+
if (fileUri.isPresent()) {
103+
Map<String, String> textDocument = new HashMap<>();
104+
textDocument.put("uri", fileUri.get());
105+
params.put("textDocument", textDocument);
106+
QEclipseEditorUtils.getSelectionRange(editor).ifPresent(range -> {
107+
Map<String, Object> cursorState = new HashMap<>();
108+
cursorState.put("range", range);
109+
params.put("cursorState", cursorState);
110+
});
111+
}
112+
} else {
113+
// Editor is null (closed), send null values
114+
params.put("textDocument", null);
115+
params.put("cursorState", null);
116+
}
117+
118+
return params;
119+
}
120+
}

plugin/src/software/aws/toolkits/eclipse/amazonq/plugin/Activator.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import software.aws.toolkits.eclipse.amazonq.inlineChat.InlineChatEditorListener;
1313
import software.aws.toolkits.eclipse.amazonq.lsp.auth.DefaultLoginService;
1414
import software.aws.toolkits.eclipse.amazonq.lsp.auth.LoginService;
15+
import software.aws.toolkits.eclipse.amazonq.lsp.editor.ActiveEditorChangeListener;
1516
import software.aws.toolkits.eclipse.amazonq.providers.browser.AmazonQBrowserProvider;
1617
import software.aws.toolkits.eclipse.amazonq.providers.lsp.LspProvider;
1718
import software.aws.toolkits.eclipse.amazonq.providers.lsp.LspProviderImpl;
@@ -39,6 +40,7 @@ public class Activator extends AbstractUIPlugin {
3940
private static ViewRouter viewRouter = ViewRouter.builder().build();
4041
private final InlineChatEditorListener editorListener;
4142
private static WorkspaceChangeListener workspaceListener = WorkspaceChangeListener.getInstance();
43+
private static ActiveEditorChangeListener activeEditorListener = ActiveEditorChangeListener.getInstance();
4244

4345
public Activator() {
4446
super();
@@ -56,6 +58,7 @@ public Activator() {
5658
editorListener = InlineChatEditorListener.getInstance();
5759
editorListener.initialize();
5860
workspaceListener.start();
61+
activeEditorListener.initialize();
5962
}
6063

6164
@Override
@@ -64,6 +67,7 @@ public final void stop(final BundleContext context) throws Exception {
6467
super.stop(context);
6568
plugin = null;
6669
workspaceListener.stop();
70+
activeEditorListener.stop();
6771
ThreadingUtils.shutdown();
6872
}
6973

plugin/src/software/aws/toolkits/eclipse/amazonq/providers/assets/ChatWebViewAssetProvider.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ private String generateJS(final String jsEntrypoint) {
144144
var disclaimerAcknowledged = Activator.getPluginStore().get(PluginStoreKeys.CHAT_DISCLAIMER_ACKNOWLEDGED);
145145
var pairProgrammingAcknowledged = Activator.getPluginStore().get(PluginStoreKeys.PAIR_PROGRAMMING_ACKNOWLEDGED);
146146
return String.format("""
147-
<script type="text/javascript" src="%s" defer></script>
147+
<script type="text/javascript" charset="UTF-8" src="%s" defer></script>
148148
<script type="text/javascript">
149149
%s
150150
const init = () => {

0 commit comments

Comments
 (0)