From f0849518580b81263fa6c806074e5828c1c32293 Mon Sep 17 00:00:00 2001
From: invictus <149003065+ashishrp-aws@users.noreply.github.com>
Date: Thu, 27 Aug 2026 10:58:30 -0700
Subject: [PATCH 1/4] feat: distinct sign-in error when Q Developer access is
blocked (#571)
* feat: distinct sign-in error when Q Developer access is blocked
Amazon Q Developer stopped accepting new Builder ID accounts. Such an account signs in
successfully -- sign-in is OIDC and is never gated -- and then finds Q silently
non-functional, because every Q request from that identity is refused and the refusal
surfaces as if it were a chat reply. No explanation, and nothing the user can act on.
Only the language server observes the refusal. The service gates on the User-Agent of the
shared language server, so the plugin's own SDK calls are allowed unconditionally and there
is no client-side signal to classify. The server reports it over the existing notification
channel, so this change teaches the plugin to listen.
- Declare aws/window/showNotification on the client and act on it.
- Advertise window.notifications. Without it the runtime builds no notification router and
drops the notification silently -- no error and no log, which is the failure mode that
cost the most time on the VS Code and JetBrains ports.
- Identify the notification by id, never by title. The runtime's router rewrites the
declared id into base64 of {"serverName":...,"id":...}, so the raw id never arrives, and
a title match would sign out a working user the first time an unrelated error reused the
title "Amazon Q Developer".
- Add QDevAccessBlockedState and resolve it in ViewRouter ahead of the logged-out state.
Reacting to the refusal signs the user out, so the two states are always true together;
checking logged out first would show the ordinary login view and lose the explanation.
- Add QDevAccessBlockedView, following ReauthenticateView, explaining what happened,
pointing to Kiro, and offering a route back to sign-in for pre-cutoff Builder IDs.
The screen carries its own copy rather than displaying the service's message: that message
is one sentence written for an API consumer and does not say what to do next. Consequently
the dates on the screen are product copy from the public announcement, not values reported
by the service.
Ports the behaviour already shipped in VS Code (aws/amazon-q-vscode#159) and JetBrains
(aws/amazon-q-jetbrains#120).
Verified: mvn -B package on Corretto 17 -- BUILD SUCCESS, 508 tests, 0 failures, including
7 new classifier tests and ViewRouterTest at 14/14 with 2 new routing tests.
* fix: seed the blocked state so the view router can emit at all
Found in Windows testing: the Amazon Q panel rendered permanently blank. The plugin
resolved and the language server launched, but no view was ever selected.
ViewRouter combines its state streams with combineLatest, which emits nothing until every
source has emitted once. Every pre-existing state is seeded during startup by the service
that owns it, but QDevAccessBlockedState has no owning service: it is only posted when the
language server reports a refusal, which for almost every user is never. The silent stream
starved the combined stream, so the router never published a view and the container had
nothing to render. The unit tests missed it because they hand-seed all streams.
Seed NOT_BLOCKED with startWithItem at the point of combination, so the stream can never
starve regardless of how the observable was injected. The clearing test now asserts the
blocked -> login ordering rather than a single post, since the seed legitimately posts the
login view once before the block arrives.
---
.../broker/events/AmazonQViewType.java | 2 +-
.../broker/events/QDevAccessBlockedState.java | 15 +++
.../broker/events/ViewRouterPluginState.java | 2 +-
.../eclipse/amazonq/lsp/AmazonQLspClient.java | 4 +
.../amazonq/lsp/AmazonQLspClientImpl.java | 33 +++++++
.../amazonq/lsp/AmazonQLspServerBuilder.java | 3 +
.../lsp/QDevAccessBlockedNotification.java | 73 ++++++++++++++
.../lsp/model/ShowNotificationParams.java | 24 +++++
.../amazonq/views/AmazonQViewContainer.java | 3 +-
.../amazonq/views/QDevAccessBlockedView.java | 99 +++++++++++++++++++
.../amazonq/views/router/ViewRouter.java | 28 ++++++
.../QDevAccessBlockedNotificationTest.java | 80 +++++++++++++++
.../amazonq/views/router/ViewRouterTest.java | 55 ++++++++++-
13 files changed, 416 insertions(+), 5 deletions(-)
create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/QDevAccessBlockedState.java
create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/QDevAccessBlockedNotification.java
create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/model/ShowNotificationParams.java
create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/views/QDevAccessBlockedView.java
create mode 100644 plugin/tst/software/aws/toolkits/eclipse/amazonq/lsp/QDevAccessBlockedNotificationTest.java
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/AmazonQViewType.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/AmazonQViewType.java
index 7ccfb74a5..3622d9b22 100644
--- a/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/AmazonQViewType.java
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/AmazonQViewType.java
@@ -6,6 +6,6 @@
public enum AmazonQViewType {
TOOLKIT_LOGIN_VIEW, CHAT_VIEW, DEPENDENCY_MISSING_VIEW, RE_AUTHENTICATE_VIEW, CHAT_ASSET_MISSING_VIEW,
- LSP_STARTUP_FAILED_VIEW
+ LSP_STARTUP_FAILED_VIEW, Q_DEV_ACCESS_BLOCKED_VIEW
}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/QDevAccessBlockedState.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/QDevAccessBlockedState.java
new file mode 100644
index 000000000..f144d1697
--- /dev/null
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/QDevAccessBlockedState.java
@@ -0,0 +1,15 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.broker.events;
+
+/**
+ * Whether Amazon Q Developer has refused this identity at sign-in.
+ *
+ *
Reacting to the refusal signs the user out, which on its own would route to the ordinary login
+ * view and lose the explanation. This state is therefore resolved ahead of the logged-out state by
+ * {@code ViewRouter}, so the user lands on a screen that says what happened.
+ */
+public enum QDevAccessBlockedState {
+ NOT_BLOCKED, BLOCKED
+}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/ViewRouterPluginState.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/ViewRouterPluginState.java
index 9a5e077fe..f20230eba 100644
--- a/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/ViewRouterPluginState.java
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/broker/events/ViewRouterPluginState.java
@@ -7,5 +7,5 @@
public record ViewRouterPluginState(AuthState authState, AmazonQLspState lspState, BrowserCompatibilityState browserCompatibilityState,
ChatWebViewAssetState chatWebViewAssetState, ToolkitLoginWebViewAssetState toolkitLoginWebViewAssetState,
- QDeveloperProfileState qDeveloperProfileState) {
+ QDeveloperProfileState qDeveloperProfileState, QDevAccessBlockedState qDevAccessBlockedState) {
}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClient.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClient.java
index 54ae81373..3770567af 100644
--- a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClient.java
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClient.java
@@ -16,6 +16,7 @@
import software.aws.toolkits.eclipse.amazonq.lsp.auth.model.SsoTokenChangedParams;
import software.aws.toolkits.eclipse.amazonq.lsp.model.ConnectionMetadata;
import software.aws.toolkits.eclipse.amazonq.lsp.model.OpenFileDiffParams;
+import software.aws.toolkits.eclipse.amazonq.lsp.model.ShowNotificationParams;
public interface AmazonQLspClient extends LanguageClient {
@@ -61,6 +62,9 @@ public interface AmazonQLspClient extends LanguageClient {
@JsonNotification("aws/didCreateDirectory")
void didCreateDirectory(Object params);
+ @JsonNotification("aws/window/showNotification")
+ void showNotification(ShowNotificationParams params);
+
@JsonNotification("aws/chat/sendPinnedContext")
void sendPinnedContext(Object params);
}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClientImpl.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClientImpl.java
index dbeda359d..70dec828d 100644
--- a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClientImpl.java
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspClientImpl.java
@@ -82,6 +82,8 @@
import software.aws.toolkits.eclipse.amazonq.lsp.auth.model.SsoTokenChangedParams;
import software.aws.toolkits.eclipse.amazonq.lsp.model.ConnectionMetadata;
import software.aws.toolkits.eclipse.amazonq.lsp.model.OpenFileDiffParams;
+import software.aws.toolkits.eclipse.amazonq.lsp.model.ShowNotificationParams;
+import software.aws.toolkits.eclipse.amazonq.broker.events.QDevAccessBlockedState;
import software.aws.toolkits.eclipse.amazonq.lsp.model.OpenTabUiResponse;
import software.aws.toolkits.eclipse.amazonq.lsp.model.SsoProfileData;
import software.aws.toolkits.eclipse.amazonq.lsp.model.TelemetryEvent;
@@ -257,6 +259,37 @@ public final void ssoTokenChanged(final SsoTokenChangedParams params) {
}
}
+ /**
+ * Handles the language server's generic notification channel. Today the only notification this
+ * plugin acts on is the report that Amazon Q Developer has refused this identity at sign-in.
+ *
+ *
Reacting means signing the user out and routing to an explanation. Sign-out happens here
+ * rather than in the view because the session is already useless: every Q request from this
+ * identity is refused, so leaving the user signed in would show a working-looking IDE that
+ * silently does nothing.
+ *
+ *
Never throws. This runs on the LSP message thread and is shared by every future
+ * notification, so a failure to classify one must not take the channel down.
+ */
+ @Override
+ public final void showNotification(final ShowNotificationParams params) {
+ try {
+ if (!QDevAccessBlockedNotification.isAccessBlocked(params)) {
+ return;
+ }
+
+ String message = params.content() == null ? null : params.content().text();
+ Activator.getLogger().info("Amazon Q Developer access is blocked for this identity: " + message);
+
+ // Publish before signing out. Sign-out makes the router re-evaluate, and the blocked
+ // state has to be in place by then or the router resolves the ordinary login view.
+ Activator.getEventBroker().post(QDevAccessBlockedState.class, QDevAccessBlockedState.BLOCKED);
+ Activator.getLoginService().logout();
+ } catch (Exception e) {
+ Activator.getLogger().error("Failed to handle showNotification", e);
+ }
+ }
+
@Override
public final void sendContextCommands(final Object params) {
var command = ChatUIInboundCommand.createCommand("aws/chat/sendContextCommands", params);
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServerBuilder.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServerBuilder.java
index 6b657c856..d43fdced6 100644
--- a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServerBuilder.java
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/AmazonQLspServerBuilder.java
@@ -63,6 +63,9 @@ private Map getInitializationOptions(final ClientMetadata metada
awsClientCapabilities.put("q", qOptions);
Map window = new HashMap<>();
window.put("showSaveFileDialog", true);
+ // Required. The runtime builds no notification router unless the client asks for one, and
+ // then drops aws/window/showNotification silently -- no error, no log.
+ window.put("notifications", true);
awsClientCapabilities.put("window", window);
awsInitOptions.put("awsClientCapabilities", awsClientCapabilities);
initOptions.put("aws", awsInitOptions);
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/QDevAccessBlockedNotification.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/QDevAccessBlockedNotification.java
new file mode 100644
index 000000000..150f9b8e5
--- /dev/null
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/QDevAccessBlockedNotification.java
@@ -0,0 +1,73 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.lsp;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import software.aws.toolkits.eclipse.amazonq.lsp.model.ShowNotificationParams;
+import software.aws.toolkits.eclipse.amazonq.util.ObjectMapperFactory;
+
+/**
+ * Recognises the notification the language server raises when Amazon Q Developer refuses an identity
+ * at sign-in.
+ *
+ * Only the language server ever observes the refusal. The service gates on the User-Agent of the
+ * shared language server, so the plugin's own SDK calls are allowed unconditionally -- there is no
+ * client-side signal to classify. The server reports it over the existing notification channel and
+ * this class decides whether a given notification is that report.
+ *
+ *
Identification is by id, never by title. The runtime's router rewrites the declared id into
+ * base64 of {@code {"serverName":...,"id":...}}, so the raw id never reaches us and a title match
+ * would sign out a working user the first time an unrelated error reused the same title.
+ */
+public final class QDevAccessBlockedNotification {
+
+ /** Id declared by the server for this notification, found inside the routed envelope. */
+ private static final String BLOCKED_NOTIFICATION_ID = "qDevPluginAccessBlocked";
+
+ private static final ObjectMapper OBJECT_MAPPER = ObjectMapperFactory.getInstance();
+
+ private QDevAccessBlockedNotification() {
+ // utility class
+ }
+
+ /**
+ * @return true when the given notification reports that this identity is blocked from Amazon Q
+ * Developer. Never throws: an unrecognised or malformed notification is simply not a
+ * match, because failing to classify one must not break the notification channel for
+ * every other message that uses it.
+ */
+ public static boolean isAccessBlocked(final ShowNotificationParams params) {
+ if (params == null) {
+ return false;
+ }
+ return BLOCKED_NOTIFICATION_ID.equals(resolveId(params.id()));
+ }
+
+ /**
+ * Resolves the id the server declared. Accepts the routed form, base64 of
+ * {@code {"serverName":...,"id":...}}, and falls back to the raw value so that a server or
+ * runtime that does not wrap the id still matches.
+ */
+ private static String resolveId(final String id) {
+ if (id == null || id.isBlank()) {
+ return null;
+ }
+ try {
+ String decoded = new String(Base64.getDecoder().decode(id), StandardCharsets.UTF_8);
+ JsonNode node = OBJECT_MAPPER.readTree(decoded);
+ JsonNode inner = node.get("id");
+ if (inner != null && inner.isTextual()) {
+ return inner.asText();
+ }
+ } catch (Exception e) {
+ // Not a routed envelope. Fall through and treat the value as a plain id.
+ }
+ return id;
+ }
+}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/model/ShowNotificationParams.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/model/ShowNotificationParams.java
new file mode 100644
index 000000000..8b914de95
--- /dev/null
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/model/ShowNotificationParams.java
@@ -0,0 +1,24 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.lsp.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+/**
+ * Parameters of the {@code aws/window/showNotification} notification, the language server's generic
+ * channel for surfacing a message to the user.
+ *
+ *
The {@code id} does not arrive as the server declared it. The runtime's router rewrites it into
+ * base64 of {@code {"serverName":...,"id":...}} so that a follow-up action can be routed back to the
+ * server that raised it. Callers must therefore decode the envelope and compare the inner id rather
+ * than this field, and must never key behaviour off {@code content.title} -- titles are shared
+ * between unrelated notifications, so matching on one would fire this handler for the wrong message.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record ShowNotificationParams(String id, String type, NotificationContent content) {
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record NotificationContent(String title, String text) {
+ }
+}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/views/AmazonQViewContainer.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/AmazonQViewContainer.java
index 427402201..22d5bbfbd 100644
--- a/plugin/src/software/aws/toolkits/eclipse/amazonq/views/AmazonQViewContainer.java
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/AmazonQViewContainer.java
@@ -35,7 +35,8 @@ AmazonQViewType.DEPENDENCY_MISSING_VIEW, new DependencyMissingView(),
AmazonQViewType.RE_AUTHENTICATE_VIEW, new ReauthenticateView(),
AmazonQViewType.LSP_STARTUP_FAILED_VIEW, new LspStartUpFailedView(),
AmazonQViewType.CHAT_VIEW, new AmazonQChatWebview(),
- AmazonQViewType.TOOLKIT_LOGIN_VIEW, new ToolkitLoginWebview());
+ AmazonQViewType.TOOLKIT_LOGIN_VIEW, new ToolkitLoginWebview(),
+ AmazonQViewType.Q_DEV_ACCESS_BLOCKED_VIEW, new QDevAccessBlockedView());
}
public AmazonQViewContainer() {
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/views/QDevAccessBlockedView.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/QDevAccessBlockedView.java
new file mode 100644
index 000000000..60d5ff552
--- /dev/null
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/QDevAccessBlockedView.java
@@ -0,0 +1,99 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.views;
+
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.events.SelectionAdapter;
+import org.eclipse.swt.events.SelectionEvent;
+import org.eclipse.swt.events.SelectionListener;
+import org.eclipse.swt.layout.GridData;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Link;
+
+import software.aws.toolkits.eclipse.amazonq.broker.events.QDevAccessBlockedState;
+import software.aws.toolkits.eclipse.amazonq.plugin.Activator;
+import software.aws.toolkits.eclipse.amazonq.util.PluginUtils;
+
+/**
+ * Shown when Amazon Q Developer refuses this identity at sign-in.
+ *
+ *
Amazon Q Developer stopped accepting new Builder ID accounts. Such an account signs in
+ * successfully -- sign-in is OIDC and is never gated -- and then finds Q silently non-functional,
+ * with the service's refusal arriving as if it were a chat reply. This view replaces that dead end
+ * with an explanation, a pointer to Kiro, and a route back to sign-in for anyone whose Builder ID
+ * predates the cutoff.
+ *
+ *
The dates and URLs below are product copy taken from the public announcement, not values
+ * reported by the service. The service's own message is deliberately not displayed: it is a single
+ * sentence written for an API consumer, and it does not say what the user should do next.
+ */
+public final class QDevAccessBlockedView extends CallToActionView {
+
+ public static final String ID = "software.aws.toolkits.eclipse.amazonq.views.QDevAccessBlockedView";
+
+ private static final String ICON_PATH = "icons/AmazonQ64.png";
+ private static final String HEADER_LABEL = "New sign-ups are no longer available";
+ private static final String SIGNUP_CUTOFF_DATE = "May 15, 2026";
+ private static final String END_OF_SUPPORT_DATE = "April 30, 2027";
+ private static final String DETAIL_MESSAGE = "Amazon Q Developer stopped accepting new accounts as of "
+ + SIGNUP_CUTOFF_DATE + ". Amazon Q Developer IDE plugins are reaching end of support on "
+ + END_OF_SUPPORT_DATE + "."
+ + System.lineSeparator() + System.lineSeparator()
+ + "Kiro includes all the AI coding features from Q Developer, plus spec-driven development and more."
+ + System.lineSeparator() + System.lineSeparator()
+ + "If your Builder ID was created before " + SIGNUP_CUTOFF_DATE
+ + ", you can still sign in -- only newly created accounts are blocked.";
+ private static final String BUTTON_LABEL = "Get started with Kiro";
+ private static final String LINK_LABEL = "Try a different login method";
+
+ private static final String KIRO_URL = "https://kiro.dev";
+
+ @Override
+ protected String getIconPath() {
+ return ICON_PATH;
+ }
+
+ @Override
+ protected String getHeaderLabel() {
+ return HEADER_LABEL;
+ }
+
+ @Override
+ protected String getDetailMessage() {
+ return DETAIL_MESSAGE;
+ }
+
+ @Override
+ protected String getButtonLabel() {
+ return BUTTON_LABEL;
+ }
+
+ @Override
+ protected SelectionListener getButtonHandler() {
+ return new SelectionAdapter() {
+ @Override
+ public void widgetSelected(final SelectionEvent e) {
+ PluginUtils.openWebpage(KIRO_URL);
+ }
+ };
+ }
+
+ @Override
+ protected void setupButtonFooterContent(final Composite composite) {
+ Link hyperlink = new Link(composite, SWT.NONE);
+ hyperlink.setText("" + LINK_LABEL + "");
+ hyperlink.setLayoutData(new GridData(SWT.CENTER, SWT.CENTER, true, false));
+ hyperlink.addSelectionListener(new SelectionAdapter() {
+ @Override
+ public void widgetSelected(final SelectionEvent e) {
+ /*
+ * Clearing the state is what returns the user to sign-in: reacting to the refusal
+ * already signed them out, so the router resolves the logged-out state as soon as
+ * this view stops taking priority. Signing out again here would be a no-op.
+ */
+ Activator.getEventBroker().post(QDevAccessBlockedState.class, QDevAccessBlockedState.NOT_BLOCKED);
+ }
+ });
+ }
+}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/views/router/ViewRouter.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/router/ViewRouter.java
index 372f6a8c8..075332a52 100644
--- a/plugin/src/software/aws/toolkits/eclipse/amazonq/views/router/ViewRouter.java
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/router/ViewRouter.java
@@ -10,6 +10,7 @@
import software.aws.toolkits.eclipse.amazonq.broker.events.AmazonQViewType;
import software.aws.toolkits.eclipse.amazonq.broker.events.BrowserCompatibilityState;
import software.aws.toolkits.eclipse.amazonq.broker.events.ChatWebViewAssetState;
+import software.aws.toolkits.eclipse.amazonq.broker.events.QDevAccessBlockedState;
import software.aws.toolkits.eclipse.amazonq.broker.events.QDeveloperProfileState;
import software.aws.toolkits.eclipse.amazonq.broker.events.ToolkitLoginWebViewAssetState;
import software.aws.toolkits.eclipse.amazonq.broker.events.ViewRouterPluginState;
@@ -69,15 +70,28 @@ private ViewRouter(final Builder builder) {
builder.qDeveloperProfileStateObservable = Activator.getEventBroker()
.ofObservable(QDeveloperProfileState.class);
}
+
+ if (builder.qDevAccessBlockedStateObservable == null) {
+ builder.qDevAccessBlockedStateObservable = Activator.getEventBroker()
+ .ofObservable(QDevAccessBlockedState.class);
+ }
/**
* Combines all state observables into a single stream that emits a new PluginState
* whenever any individual state changes. The combined stream:
* - Waits for initial events from all observables before emitting
* - Creates new PluginState from latest values of each observable upon update to any single stream
*/
+ /*
+ * combineLatest emits nothing until every source has emitted once. The other states are all
+ * seeded by their owning service during startup, but the blocked state has no owning
+ * service: it is only ever posted when the language server reports a refusal, which for
+ * almost every user is never. Without a seed value this stream would starve the combined
+ * stream and the router would never select a view -- the panel stays permanently blank.
+ */
Observable.combineLatest(builder.authStateObservable, builder.lspStateObservable,
builder.browserCompatibilityStateObservable, builder.chatWebViewAssetStateObservable,
builder.toolkitLoginWebViewAssetStateObservable, builder.qDeveloperProfileStateObservable,
+ builder.qDevAccessBlockedStateObservable.startWithItem(QDevAccessBlockedState.NOT_BLOCKED),
ViewRouterPluginState::new).observeOn(Schedulers.computation()).subscribe(this::onEvent);
}
@@ -117,6 +131,13 @@ private void refreshActiveView(final ViewRouterPluginState pluginState) {
} else if (pluginState.chatWebViewAssetState() == ChatWebViewAssetState.DEPENDENCY_MISSING
|| pluginState.toolkitLoginWebViewAssetState() == ToolkitLoginWebViewAssetState.DEPENDENCY_MISSING) {
newActiveView = AmazonQViewType.CHAT_ASSET_MISSING_VIEW;
+ } else if (pluginState.qDevAccessBlockedState() == QDevAccessBlockedState.BLOCKED) {
+ /*
+ * Resolved ahead of the logged-out state on purpose. Reacting to the refusal signs the
+ * user out, so this state and the logged-out state are always true together; checking
+ * logged out first would show the ordinary login view and lose the explanation.
+ */
+ newActiveView = AmazonQViewType.Q_DEV_ACCESS_BLOCKED_VIEW;
} else if (pluginState.authState().isLoggedOut()) {
newActiveView = AmazonQViewType.TOOLKIT_LOGIN_VIEW;
} else if (pluginState.authState().isExpired()) {
@@ -160,6 +181,7 @@ public static final class Builder {
private Observable chatWebViewAssetStateObservable;
private Observable toolkitLoginWebViewAssetStateObservable;
private Observable qDeveloperProfileStateObservable;
+ private Observable qDevAccessBlockedStateObservable;
public Builder withAuthStateObservable(final Observable authStateObservable) {
this.authStateObservable = authStateObservable;
@@ -195,6 +217,12 @@ public Builder withQDeveloperProfileStateObservable(
return this;
}
+ public Builder withQDevAccessBlockedStateObservable(
+ final Observable qDevAccessBlockedStateObservable) {
+ this.qDevAccessBlockedStateObservable = qDevAccessBlockedStateObservable;
+ return this;
+ }
+
public ViewRouter build() {
return new ViewRouter(this);
}
diff --git a/plugin/tst/software/aws/toolkits/eclipse/amazonq/lsp/QDevAccessBlockedNotificationTest.java b/plugin/tst/software/aws/toolkits/eclipse/amazonq/lsp/QDevAccessBlockedNotificationTest.java
new file mode 100644
index 000000000..45a45dab9
--- /dev/null
+++ b/plugin/tst/software/aws/toolkits/eclipse/amazonq/lsp/QDevAccessBlockedNotificationTest.java
@@ -0,0 +1,80 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.lsp;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+import org.junit.jupiter.api.Test;
+
+import software.aws.toolkits.eclipse.amazonq.lsp.model.ShowNotificationParams;
+import software.aws.toolkits.eclipse.amazonq.lsp.model.ShowNotificationParams.NotificationContent;
+
+public final class QDevAccessBlockedNotificationTest {
+
+ private static final String BLOCKED_ID = "qDevPluginAccessBlocked";
+ private static final String SHARED_TITLE = "Amazon Q Developer";
+
+ private static String routed(final String id) {
+ String envelope = "{\"serverName\":\"AmazonQ-For-Eclipse\",\"id\":\"" + id + "\"}";
+ return Base64.getEncoder().encodeToString(envelope.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static ShowNotificationParams params(final String id, final String title) {
+ return new ShowNotificationParams(id, "error", new NotificationContent(title, "blocked message"));
+ }
+
+ @Test
+ void matchesTheRoutedEnvelopeTheRuntimeActuallySends() {
+ assertTrue(QDevAccessBlockedNotification.isAccessBlocked(params(routed(BLOCKED_ID), SHARED_TITLE)));
+ }
+
+ @Test
+ void matchesAPlainIdSoAnUnroutedServerStillWorks() {
+ assertTrue(QDevAccessBlockedNotification.isAccessBlocked(params(BLOCKED_ID, SHARED_TITLE)));
+ }
+
+ /**
+ * The whole point of matching on the id: an unrelated error that happens to share the title must
+ * not sign the user out. Matching on title instead would fire here.
+ */
+ @Test
+ void ignoresADifferentNotificationThatSharesTheTitle() {
+ assertFalse(QDevAccessBlockedNotification.isAccessBlocked(params(routed("someOtherError"), SHARED_TITLE)));
+ }
+
+ @Test
+ void ignoresANotificationWithNoId() {
+ assertFalse(QDevAccessBlockedNotification.isAccessBlocked(params(null, SHARED_TITLE)));
+ assertFalse(QDevAccessBlockedNotification.isAccessBlocked(params("", SHARED_TITLE)));
+ }
+
+ @Test
+ void ignoresNullParams() {
+ assertFalse(QDevAccessBlockedNotification.isAccessBlocked(null));
+ }
+
+ /**
+ * Malformed input must be a non-match rather than an exception: this classifier sits on the
+ * shared notification channel, so throwing would break every other notification.
+ */
+ @Test
+ void treatsMalformedInputAsNotBlocked() {
+ assertFalse(QDevAccessBlockedNotification.isAccessBlocked(params("!!!not-base64!!!", SHARED_TITLE)));
+ String base64OfNonJson = Base64.getEncoder().encodeToString("not json".getBytes(StandardCharsets.UTF_8));
+ assertFalse(QDevAccessBlockedNotification.isAccessBlocked(params(base64OfNonJson, SHARED_TITLE)));
+ String envelopeWithoutId = Base64.getEncoder()
+ .encodeToString("{\"serverName\":\"x\"}".getBytes(StandardCharsets.UTF_8));
+ assertFalse(QDevAccessBlockedNotification.isAccessBlocked(params(envelopeWithoutId, SHARED_TITLE)));
+ }
+
+ @Test
+ void doesNotRequireContentToBePresent() {
+ assertTrue(QDevAccessBlockedNotification
+ .isAccessBlocked(new ShowNotificationParams(routed(BLOCKED_ID), "error", null)));
+ }
+}
diff --git a/plugin/tst/software/aws/toolkits/eclipse/amazonq/views/router/ViewRouterTest.java b/plugin/tst/software/aws/toolkits/eclipse/amazonq/views/router/ViewRouterTest.java
index 7e7b88698..99fac6654 100644
--- a/plugin/tst/software/aws/toolkits/eclipse/amazonq/views/router/ViewRouterTest.java
+++ b/plugin/tst/software/aws/toolkits/eclipse/amazonq/views/router/ViewRouterTest.java
@@ -4,6 +4,7 @@
package software.aws.toolkits.eclipse.amazonq.views.router;
import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.verify;
import java.util.stream.Stream;
@@ -11,6 +12,8 @@
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+
+import org.mockito.InOrder;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
@@ -25,6 +28,7 @@
import software.aws.toolkits.eclipse.amazonq.broker.events.AmazonQViewType;
import software.aws.toolkits.eclipse.amazonq.broker.events.BrowserCompatibilityState;
import software.aws.toolkits.eclipse.amazonq.broker.events.ChatWebViewAssetState;
+import software.aws.toolkits.eclipse.amazonq.broker.events.QDevAccessBlockedState;
import software.aws.toolkits.eclipse.amazonq.broker.events.QDeveloperProfileState;
import software.aws.toolkits.eclipse.amazonq.broker.events.ToolkitLoginWebViewAssetState;
import software.aws.toolkits.eclipse.amazonq.extensions.implementation.ActivatorStaticMockExtension;
@@ -41,6 +45,7 @@ public final class ViewRouterTest {
private Observable chatWebViewAssetStateObservable;
private Observable toolkitLoginWebViewAssetStateObservable;
private Observable qDeveloperProfileStateObservable;
+ private Observable qDevAccessBlockedStateObservable;
private ViewRouter viewRouter;
private EventBroker eventBrokerMock;
@@ -61,6 +66,7 @@ void setupBeforeEach() {
chatWebViewAssetStateObservable = publishSubject.ofType(ChatWebViewAssetState.class);
toolkitLoginWebViewAssetStateObservable = publishSubject.ofType(ToolkitLoginWebViewAssetState.class);
qDeveloperProfileStateObservable = publishSubject.ofType(QDeveloperProfileState.class);
+ qDevAccessBlockedStateObservable = publishSubject.ofType(QDevAccessBlockedState.class);
eventBrokerMock = activatorStaticMockExtension.getMock(EventBroker.class);
@@ -69,7 +75,8 @@ void setupBeforeEach() {
.withBrowserCompatibilityStateObservable(browserCompatibilityStateObservable)
.withChatWebViewAssetStateObservable(chatWebViewAssetStateObservable)
.withToolkitLoginWebViewAssetStateObservable(toolkitLoginWebViewAssetStateObservable)
- .withQDeveloperProfileStateObservable(qDeveloperProfileStateObservable).build();
+ .withQDeveloperProfileStateObservable(qDeveloperProfileStateObservable)
+ .withQDevAccessBlockedStateObservable(qDevAccessBlockedStateObservable).build();
}
@AfterEach
@@ -89,11 +96,54 @@ void testActiveViewResolutionBasedOnPluginState(final AmazonQLspState lspState,
publishSubject.onNext(browserCompatibilityState);
publishSubject.onNext(chatWebViewAssetState);
publishSubject.onNext(toolkitLoginWebViewAssetState);
- publishSubject.onNext(QDeveloperProfileState.AVAILABLE); // does not affect view selection
+ publishSubject.onNext(QDeveloperProfileState.AVAILABLE);
+ publishSubject.onNext(QDevAccessBlockedState.NOT_BLOCKED); // does not affect view selection
verify(eventBrokerMock).post(AmazonQViewType.class, expectedActiveViewType);
}
+ /**
+ * Reacting to the refusal signs the user out, so BLOCKED and logged-out are always true
+ * together. The router must resolve BLOCKED first, otherwise the user lands on the ordinary
+ * login view and the explanation is lost.
+ */
+ @Test
+ void testAccessBlockedTakesPriorityOverLoggedOut() {
+ publishSubject.onNext(getAuthStateObject(AuthStateType.LOGGED_OUT));
+ publishSubject.onNext(AmazonQLspState.ACTIVE);
+ publishSubject.onNext(BrowserCompatibilityState.COMPATIBLE);
+ publishSubject.onNext(ChatWebViewAssetState.RESOLVED);
+ publishSubject.onNext(ToolkitLoginWebViewAssetState.RESOLVED);
+ publishSubject.onNext(QDeveloperProfileState.AVAILABLE);
+ publishSubject.onNext(QDevAccessBlockedState.BLOCKED);
+
+ verify(eventBrokerMock).post(AmazonQViewType.class, AmazonQViewType.Q_DEV_ACCESS_BLOCKED_VIEW);
+ }
+
+ /**
+ * Clearing the state is what returns the user to sign-in: they are already signed out, so the
+ * router should fall through to the login view.
+ */
+ @Test
+ void testClearingAccessBlockedReturnsToLoginView() {
+ publishSubject.onNext(getAuthStateObject(AuthStateType.LOGGED_OUT));
+ publishSubject.onNext(AmazonQLspState.ACTIVE);
+ publishSubject.onNext(BrowserCompatibilityState.COMPATIBLE);
+ publishSubject.onNext(ChatWebViewAssetState.RESOLVED);
+ publishSubject.onNext(ToolkitLoginWebViewAssetState.RESOLVED);
+ publishSubject.onNext(QDeveloperProfileState.AVAILABLE);
+ publishSubject.onNext(QDevAccessBlockedState.BLOCKED);
+ publishSubject.onNext(QDevAccessBlockedState.NOT_BLOCKED);
+
+ /*
+ * The router seeds NOT_BLOCKED via startWithItem, so the login view is posted once before
+ * BLOCKED arrives and once after it is cleared. Assert the sequence rather than a count.
+ */
+ InOrder inOrder = inOrder(eventBrokerMock);
+ inOrder.verify(eventBrokerMock).post(AmazonQViewType.class, AmazonQViewType.Q_DEV_ACCESS_BLOCKED_VIEW);
+ inOrder.verify(eventBrokerMock).post(AmazonQViewType.class, AmazonQViewType.TOOLKIT_LOGIN_VIEW);
+ }
+
@Test
void testDuplicateViewIdPublishedWhenDeveloperProfileSelected() {
publishSubject.onNext(getAuthStateObject(AuthStateType.LOGGED_IN));
@@ -102,6 +152,7 @@ void testDuplicateViewIdPublishedWhenDeveloperProfileSelected() {
publishSubject.onNext(ChatWebViewAssetState.RESOLVED);
publishSubject.onNext(ToolkitLoginWebViewAssetState.RESOLVED);
publishSubject.onNext(QDeveloperProfileState.AVAILABLE);
+ publishSubject.onNext(QDevAccessBlockedState.NOT_BLOCKED);
publishSubject.onNext(getAuthStateObject(AuthStateType.LOGGED_IN));
publishSubject.onNext(AmazonQLspState.ACTIVE);
From 04cf222affd9e41b44c247ddf592193c25d39030 Mon Sep 17 00:00:00 2001
From: araarush
Date: Mon, 28 Sep 2026 10:24:09 -0700
Subject: [PATCH 2/4] Add Eclipse deprecation notice to README (#579)
---
README.md | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/README.md b/README.md
index 89f86a26f..80f661c08 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,12 @@
## Amazon Q for Eclipse
+> [!IMPORTANT]
+> **Amazon Q Developer IDE plugins: end of support**
+>
+> On April 30, 2027, AWS will discontinue support for Amazon Q Developer IDE plugins. For capabilities similar to Amazon Q Developer IDE plugins, [explore Kiro](https://kiro.dev) to access the latest models and features, including agentic coding, chat and MCP support.
+>
+> [Learn more](https://aws.amazon.com/blogs/devops/amazon-q-developer-end-of-support-announcement/)
+
Amazon Q Developer is an advanced AI-powered coding assistant designed to enhance developer productivity and streamline software development processes.
### Key Features
From e4378fadbe92fcef97657f0f03c42f5a50563475 Mon Sep 17 00:00:00 2001
From: araarush
Date: Tue, 29 Sep 2026 14:50:19 -0700
Subject: [PATCH 3/4] Add Amazon Q editor deprecation banner (#581)
---
plugin/plugin.xml | 23 ++
.../configuration/PluginStoreKeys.java | 1 +
.../amazonq/lsp/LspStartupActivity.java | 16 +-
.../eclipse/amazonq/plugin/Activator.java | 2 +
.../eclipse/amazonq/util/Constants.java | 12 +-
.../amazonq/util/KiroSunsetNotification.java | 63 ----
.../views/DeprecationBannerManager.java | 317 ++++++++++++++++++
.../views/DeprecationBannerSession.java | 42 +++
.../amazonq/views/DeprecationBannerView.java | 176 ++++++++++
.../util/DeprecationNoticeContentTest.java | 24 ++
.../views/DeprecationBannerManagerTest.java | 248 ++++++++++++++
.../views/DeprecationBannerSessionTest.java | 54 +++
12 files changed, 897 insertions(+), 81 deletions(-)
delete mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/util/KiroSunsetNotification.java
create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/views/DeprecationBannerManager.java
create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/views/DeprecationBannerSession.java
create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/views/DeprecationBannerView.java
create mode 100644 plugin/tst/software/aws/toolkits/eclipse/amazonq/util/DeprecationNoticeContentTest.java
create mode 100644 plugin/tst/software/aws/toolkits/eclipse/amazonq/views/DeprecationBannerManagerTest.java
create mode 100644 plugin/tst/software/aws/toolkits/eclipse/amazonq/views/DeprecationBannerSessionTest.java
diff --git a/plugin/plugin.xml b/plugin/plugin.xml
index f87e519b1..d3d0742a7 100644
--- a/plugin/plugin.xml
+++ b/plugin/plugin.xml
@@ -132,6 +132,13 @@
name="Amazon Q Code Reference"
restorable="true">
+
+
@@ -146,6 +153,11 @@
isEqualityPattern="true"
pattern="amazon-q-eclipse/software.aws.toolkits.eclipse.amazonq.views.AmazonQViewContainer">
+
+
@@ -158,6 +170,17 @@
relationship="right"
visible="false">
+
+
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/configuration/PluginStoreKeys.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/configuration/PluginStoreKeys.java
index 5787ad11c..8b3638e0d 100644
--- a/plugin/src/software/aws/toolkits/eclipse/amazonq/configuration/PluginStoreKeys.java
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/configuration/PluginStoreKeys.java
@@ -11,5 +11,6 @@ private PluginStoreKeys() {
public static final String CHAT_DISCLAIMER_ACKNOWLEDGED = "qchatDisclaimerAcknowledged";
public static final String PAIR_PROGRAMMING_ACKNOWLEDGED = "qchatPairProgrammingAcknowledged";
+ public static final String EDITOR_DEPRECATION_BANNER_DISMISSED = "editorDeprecationBannerDismissed";
}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/LspStartupActivity.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/LspStartupActivity.java
index 3a43ed19a..178620edd 100644
--- a/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/LspStartupActivity.java
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/LspStartupActivity.java
@@ -27,10 +27,10 @@
import software.aws.toolkits.eclipse.amazonq.util.AutoTriggerPartListener;
import software.aws.toolkits.eclipse.amazonq.util.AutoTriggerTopLevelListener;
import software.aws.toolkits.eclipse.amazonq.util.Constants;
-import software.aws.toolkits.eclipse.amazonq.util.KiroSunsetNotification;
import software.aws.toolkits.eclipse.amazonq.util.ThreadingUtils;
import software.aws.toolkits.eclipse.amazonq.util.ToolkitNotification;
import software.aws.toolkits.eclipse.amazonq.util.UpdateUtils;
+import software.aws.toolkits.eclipse.amazonq.views.DeprecationBannerManager;
import software.aws.toolkits.eclipse.amazonq.views.ViewConstants;
import software.aws.toolkits.eclipse.amazonq.views.ViewVisibilityManager;
import software.aws.toolkits.eclipse.amazonq.views.actions.AmazonQToolbarActions;
@@ -41,6 +41,8 @@ public class LspStartupActivity implements IStartup {
@Override
public final void earlyStartup() {
+ Display.getDefault().asyncExec(
+ () -> DeprecationBannerManager.getInstance().showOncePerSession());
Job startupJob = new Job("Amazon Q Startup") {
@Override
protected IStatus run(final IProgressMonitor monitor) {
@@ -82,23 +84,11 @@ private void schedulePostStartupJobs() {
Display.getDefault().asyncExec(() -> launchWebview());
}
Display.getDefault().asyncExec(() -> attachAutoTriggerListenersIfApplicable());
- Display.getDefault().asyncExec(() -> showKiroSunsetNotification());
checkForUpdates();
NotificationPollingService.getInstance().start();
});
}
- private void showKiroSunsetNotification() {
- if (Activator.getPluginStore().get(Constants.KIRO_SUNSET_NOTIFICATION_DISMISSED_KEY) != null) {
- return;
- }
- AbstractNotificationPopup notification = new KiroSunsetNotification(Display.getCurrent(),
- Constants.KIRO_SUNSET_NOTIFICATION_TITLE,
- Constants.KIRO_SUNSET_NOTIFICATION_BODY,
- () -> Activator.getPluginStore().put(Constants.KIRO_SUNSET_NOTIFICATION_DISMISSED_KEY, "true"));
- notification.open();
- }
-
private void checkForUpdates() {
Job updateCheckJob = new Job("Check for updates") {
@Override
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/plugin/Activator.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/plugin/Activator.java
index fcd3221a6..bf9e53573 100644
--- a/plugin/src/software/aws/toolkits/eclipse/amazonq/plugin/Activator.java
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/plugin/Activator.java
@@ -24,6 +24,7 @@
import software.aws.toolkits.eclipse.amazonq.util.PluginLogger;
import software.aws.toolkits.eclipse.amazonq.notifications.NotificationPollingService;
import software.aws.toolkits.eclipse.amazonq.util.ThreadingUtils;
+import software.aws.toolkits.eclipse.amazonq.views.DeprecationBannerManager;
import software.aws.toolkits.eclipse.amazonq.views.router.ViewRouter;
import software.aws.toolkits.eclipse.workspace.WorkspaceChangeListener;
@@ -64,6 +65,7 @@ public Activator() {
@Override
public final void stop(final BundleContext context) throws Exception {
+ DeprecationBannerManager.getInstance().shutdown();
NotificationPollingService.getInstance().shutdown();
AmazonQBrowserProvider.getInstance().dispose();
super.stop(context);
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/util/Constants.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/util/Constants.java
index 82294da69..666e5427b 100644
--- a/plugin/src/software/aws/toolkits/eclipse/amazonq/util/Constants.java
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/util/Constants.java
@@ -40,11 +40,13 @@ private Constants() {
public static final String TELEMETRY_NOTIFICATION_TITLE = "AWS IDE plugins telemetry";
public static final String TELEMETRY_NOTIFICATION_BODY = "Usage metrics are collected by default. This can be changed in the \"Amazon Q\" section"
+ " of the IDE preferences.";
- public static final String KIRO_SUNSET_NOTIFICATION_TITLE = "Amazon Q Developer end of support";
- public static final String KIRO_SUNSET_NOTIFICATION_BODY = "Amazon Q Developer IDE plugins will reach end of support on April 30, 2027."
- + " New accounts will no longer be available starting May 15, 2026";
- public static final String KIRO_SUNSET_LEARN_MORE_URL = "https://aws.amazon.com/blogs/devops/amazon-q-developer-end-of-support-announcement/";
- public static final String KIRO_SUNSET_NOTIFICATION_DISMISSED_KEY = "kiroSunsetNotificationDismissed";
+ public static final String DEPRECATION_NOTICE_TITLE = "Amazon Q Developer IDE plugins: end of support";
+ public static final String DEPRECATION_NOTICE_BODY = "On April 30, 2027, AWS will discontinue support for Amazon Q Developer IDE plugins. "
+ + "For capabilities similar to Amazon Q Developer IDE plugins, explore Kiro to access the latest models and features, including agentic coding, "
+ + "chat and MCP support.";
+ public static final String KIRO_URL = "https://kiro.dev";
+ public static final String DEPRECATION_NOTICE_LEARN_MORE_URL =
+ "https://aws.amazon.com/blogs/devops/amazon-q-developer-end-of-support-announcement/";
public static final String RE_AUTHENTICATE_FAILURE_MESSAGE = "An error occurred while attempting to re-authenticate. Please try again.";
public static final String AUTHENTICATE_FAILURE_MESSAGE = "An error occurred while attempting to authenticate. Please try again.";
public static final String IDE_SSL_HANDSHAKE_TITLE = "SSL Handshake Error";
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/util/KiroSunsetNotification.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/util/KiroSunsetNotification.java
deleted file mode 100644
index ad584e6dd..000000000
--- a/plugin/src/software/aws/toolkits/eclipse/amazonq/util/KiroSunsetNotification.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-package software.aws.toolkits.eclipse.amazonq.util;
-
-import org.eclipse.swt.SWT;
-import org.eclipse.swt.events.SelectionAdapter;
-import org.eclipse.swt.events.SelectionEvent;
-import org.eclipse.swt.layout.GridData;
-import org.eclipse.swt.layout.GridLayout;
-import org.eclipse.swt.widgets.Button;
-import org.eclipse.swt.widgets.Composite;
-import org.eclipse.swt.widgets.Display;
-
-public final class KiroSunsetNotification extends ToolkitNotification {
-
- private final Runnable onDismiss;
-
- public KiroSunsetNotification(final Display display, final String title,
- final String description, final Runnable onDismiss) {
- super(display, title, description);
- this.onDismiss = onDismiss;
- }
-
- @Override
- protected void createContentArea(final Composite parent) {
- super.createContentArea(parent);
-
- Composite buttonRow = new Composite(parent, SWT.NONE);
- GridLayout layout = new GridLayout(2, false);
- layout.marginWidth = 0;
- layout.marginHeight = 0;
- buttonRow.setLayout(layout);
- buttonRow.setLayoutData(new GridData(SWT.END, SWT.CENTER, true, false));
-
- Button learnMoreButton = new Button(buttonRow, SWT.PUSH);
- learnMoreButton.setText("Learn more");
- learnMoreButton.setLayoutData(new GridData(SWT.END, SWT.CENTER, false, false));
- learnMoreButton.addSelectionListener(new SelectionAdapter() {
- @Override
- public void widgetSelected(final SelectionEvent e) {
- PluginUtils.openWebpage(Constants.KIRO_SUNSET_LEARN_MORE_URL);
- }
- });
-
- Button dismissButton = new Button(buttonRow, SWT.PUSH);
- dismissButton.setText("Dismiss");
- dismissButton.setLayoutData(new GridData(SWT.END, SWT.CENTER, false, false));
- dismissButton.addSelectionListener(new SelectionAdapter() {
- @Override
- public void widgetSelected(final SelectionEvent e) {
- dismiss();
- }
- });
- }
-
- private void dismiss() {
- if (onDismiss != null) {
- onDismiss.run();
- }
- close();
- }
-}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/views/DeprecationBannerManager.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/DeprecationBannerManager.java
new file mode 100644
index 000000000..93b9341d5
--- /dev/null
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/DeprecationBannerManager.java
@@ -0,0 +1,317 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.views;
+
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.ui.IPageListener;
+import org.eclipse.ui.IPerspectiveDescriptor;
+import org.eclipse.ui.IPerspectiveListener;
+import org.eclipse.ui.IViewPart;
+import org.eclipse.ui.IWindowListener;
+import org.eclipse.ui.IWorkbench;
+import org.eclipse.ui.IWorkbenchPage;
+import org.eclipse.ui.IWorkbenchWindow;
+import org.eclipse.ui.PartInitException;
+import org.eclipse.ui.PlatformUI;
+
+import software.aws.toolkits.eclipse.amazonq.configuration.PluginStore;
+import software.aws.toolkits.eclipse.amazonq.configuration.PluginStoreKeys;
+import software.aws.toolkits.eclipse.amazonq.plugin.Activator;
+
+/**
+ * Presents a single editor-area deprecation banner without activating or focusing it.
+ */
+public final class DeprecationBannerManager {
+
+ private static final int ATTACH_RETRY_DELAY_MILLIS = 250;
+ private static final int MAX_ATTACH_RETRIES = 40;
+
+ private final DeprecationBannerSession session;
+ private final IWorkbench workbench;
+ private final Runnable attachRetry;
+ private final IPageListener pageListener;
+ private final IPerspectiveListener perspectiveListener;
+ private final IWindowListener windowListener;
+ private IWorkbenchWindow presentationWindow;
+ private int remainingAttachRetries;
+ private boolean attachRetryScheduled;
+ private boolean pageListenersRegistered;
+ private boolean windowListenerRegistered;
+
+ DeprecationBannerManager(final DeprecationBannerSession session, final IWorkbench workbench) {
+ this.session = session;
+ this.workbench = workbench;
+ this.attachRetry = () -> {
+ attachRetryScheduled = false;
+ if (!session.isPresentationActive() || presentationWindow != null) {
+ return;
+ }
+ if (!attachToAvailableWindow(null)) {
+ scheduleAttachRetry();
+ }
+ };
+ this.pageListener = new IPageListener() {
+ @Override
+ public void pageActivated(final IWorkbenchPage page) {
+ attachIfUnassigned(page.getWorkbenchWindow());
+ }
+
+ @Override
+ public void pageClosed(final IWorkbenchPage page) {
+ // A replacement page is handled when it is opened or activated.
+ }
+
+ @Override
+ public void pageOpened(final IWorkbenchPage page) {
+ attachIfUnassigned(page.getWorkbenchWindow());
+ }
+ };
+ this.perspectiveListener = new IPerspectiveListener() {
+ @Override
+ public void perspectiveActivated(final IWorkbenchPage page,
+ final IPerspectiveDescriptor perspective) {
+ showInPage(page);
+ }
+
+ @Override
+ public void perspectiveChanged(final IWorkbenchPage page,
+ final IPerspectiveDescriptor perspective, final String changeId) {
+ // The banner only needs to react when a different perspective becomes active.
+ }
+ };
+ this.windowListener = new IWindowListener() {
+ @Override
+ public void windowActivated(final IWorkbenchWindow window) {
+ attachIfUnassigned(window);
+ }
+
+ @Override
+ public void windowDeactivated(final IWorkbenchWindow window) {
+ // The banner remains attached to its current workbench window.
+ }
+
+ @Override
+ public void windowClosed(final IWorkbenchWindow window) {
+ window.removePageListener(pageListener);
+ transferFromClosedWindow(window);
+ }
+
+ @Override
+ public void windowOpened(final IWorkbenchWindow window) {
+ window.addPageListener(pageListener);
+ attachIfUnassigned(window);
+ }
+ };
+ }
+
+ public static DeprecationBannerManager getInstance() {
+ return InstanceHolder.INSTANCE;
+ }
+
+ public void showOncePerSession() {
+ if (!session.claimPresentation()) {
+ return;
+ }
+
+ registerWindowListener();
+ registerPageListeners();
+ remainingAttachRetries = MAX_ATTACH_RETRIES;
+ if (!attachToAvailableWindow(null)) {
+ scheduleAttachRetry();
+ }
+ }
+
+ public boolean isPresentationActive() {
+ return session.isPresentationActive();
+ }
+
+ public void dismissPermanently() {
+ if (!session.dismissPermanently()) {
+ return;
+ }
+ hideAllViews();
+ cancelAttachRetry();
+ detachPerspectiveListener();
+ unregisterPageListeners();
+ unregisterWindowListener();
+ }
+
+ public void shutdown() {
+ cancelAttachRetry();
+ detachPerspectiveListener();
+ unregisterPageListeners();
+ unregisterWindowListener();
+ }
+
+ private boolean showInPage(final IWorkbenchPage page) {
+ if (page == null || !session.isPresentationActive()) {
+ return false;
+ }
+ try {
+ page.showView(DeprecationBannerView.ID, null, IWorkbenchPage.VIEW_VISIBLE);
+ return true;
+ } catch (PartInitException e) {
+ Activator.getLogger().warn("Unable to show the Amazon Q end-of-support banner", e);
+ return false;
+ }
+ }
+
+ private boolean attachToAvailableWindow(final IWorkbenchWindow excludedWindow) {
+ IWorkbenchWindow activeWindow = workbench.getActiveWorkbenchWindow();
+ if (isEligible(activeWindow, excludedWindow) && attachToWindow(activeWindow)) {
+ return true;
+ }
+
+ for (IWorkbenchWindow window : workbench.getWorkbenchWindows()) {
+ if (window != activeWindow
+ && isEligible(window, excludedWindow)
+ && attachToWindow(window)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean isEligible(final IWorkbenchWindow window,
+ final IWorkbenchWindow excludedWindow) {
+ return window != null && window != excludedWindow && window.getActivePage() != null;
+ }
+
+ private boolean attachToWindow(final IWorkbenchWindow window) {
+ if (!session.isPresentationActive() || presentationWindow != null) {
+ return false;
+ }
+
+ IWorkbenchPage page = window.getActivePage();
+ if (!showInPage(page)) {
+ return false;
+ }
+
+ presentationWindow = window;
+ presentationWindow.addPerspectiveListener(perspectiveListener);
+ cancelAttachRetry();
+ return true;
+ }
+
+ private void attachIfUnassigned(final IWorkbenchWindow window) {
+ if (presentationWindow == null && isEligible(window, null)) {
+ attachToWindow(window);
+ }
+ }
+
+ private void transferFromClosedWindow(final IWorkbenchWindow closedWindow) {
+ if (closedWindow != presentationWindow) {
+ return;
+ }
+
+ detachPerspectiveListener();
+ if (!attachToAvailableWindow(closedWindow)) {
+ remainingAttachRetries = MAX_ATTACH_RETRIES;
+ scheduleAttachRetry();
+ }
+ }
+
+ private void hideAllViews() {
+ Display display = workbench.getDisplay();
+ Runnable hideViews = () -> {
+ for (IWorkbenchWindow window : workbench.getWorkbenchWindows()) {
+ for (IWorkbenchPage page : window.getPages()) {
+ IViewPart view = page.findView(DeprecationBannerView.ID);
+ if (view != null) {
+ page.hideView(view);
+ }
+ }
+ }
+ };
+
+ if (display == null || display.isDisposed()) {
+ return;
+ }
+ if (display.getThread() == Thread.currentThread()) {
+ hideViews.run();
+ } else {
+ display.asyncExec(hideViews);
+ }
+ }
+
+ private void detachPerspectiveListener() {
+ if (presentationWindow != null) {
+ presentationWindow.removePerspectiveListener(perspectiveListener);
+ }
+ presentationWindow = null;
+ }
+
+ private void scheduleAttachRetry() {
+ if (attachRetryScheduled || remainingAttachRetries <= 0
+ || !session.isPresentationActive()) {
+ return;
+ }
+
+ Display display = workbench.getDisplay();
+ if (display == null || display.isDisposed()) {
+ return;
+ }
+
+ remainingAttachRetries--;
+ attachRetryScheduled = true;
+ display.timerExec(ATTACH_RETRY_DELAY_MILLIS, attachRetry);
+ }
+
+ private void cancelAttachRetry() {
+ remainingAttachRetries = 0;
+ if (!attachRetryScheduled) {
+ return;
+ }
+
+ attachRetryScheduled = false;
+ Display display = workbench.getDisplay();
+ if (display != null && !display.isDisposed()) {
+ display.timerExec(-1, attachRetry);
+ }
+ }
+
+ private void registerWindowListener() {
+ if (!windowListenerRegistered) {
+ workbench.addWindowListener(windowListener);
+ windowListenerRegistered = true;
+ }
+ }
+
+ private void registerPageListeners() {
+ if (!pageListenersRegistered) {
+ for (IWorkbenchWindow window : workbench.getWorkbenchWindows()) {
+ window.addPageListener(pageListener);
+ }
+ pageListenersRegistered = true;
+ }
+ }
+
+ private void unregisterPageListeners() {
+ if (pageListenersRegistered) {
+ for (IWorkbenchWindow window : workbench.getWorkbenchWindows()) {
+ window.removePageListener(pageListener);
+ }
+ pageListenersRegistered = false;
+ }
+ }
+
+ private void unregisterWindowListener() {
+ if (windowListenerRegistered) {
+ workbench.removeWindowListener(windowListener);
+ windowListenerRegistered = false;
+ }
+ }
+
+ private static DeprecationBannerManager createDefault() {
+ PluginStore store = Activator.getPluginStore();
+ return new DeprecationBannerManager(new DeprecationBannerSession(
+ () -> store.get(PluginStoreKeys.EDITOR_DEPRECATION_BANNER_DISMISSED) != null,
+ () -> store.put(PluginStoreKeys.EDITOR_DEPRECATION_BANNER_DISMISSED, "true")),
+ PlatformUI.getWorkbench());
+ }
+
+ private static final class InstanceHolder {
+ private static final DeprecationBannerManager INSTANCE = createDefault();
+ }
+}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/views/DeprecationBannerSession.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/DeprecationBannerSession.java
new file mode 100644
index 000000000..d482403dc
--- /dev/null
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/DeprecationBannerSession.java
@@ -0,0 +1,42 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.views;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.BooleanSupplier;
+
+/**
+ * Owns the single logical editor-banner presentation for the current IDE process.
+ */
+final class DeprecationBannerSession {
+
+ private final BooleanSupplier permanentlyDismissed;
+ private final Runnable persistDismissal;
+ private final AtomicBoolean presentationClaimed = new AtomicBoolean();
+ private final AtomicBoolean dismissedThisSession = new AtomicBoolean();
+
+ DeprecationBannerSession(final BooleanSupplier permanentlyDismissed,
+ final Runnable persistDismissal) {
+ this.permanentlyDismissed = permanentlyDismissed;
+ this.persistDismissal = persistDismissal;
+ }
+
+ boolean claimPresentation() {
+ return !permanentlyDismissed.getAsBoolean()
+ && !dismissedThisSession.get()
+ && presentationClaimed.compareAndSet(false, true);
+ }
+
+ boolean isPresentationActive() {
+ return presentationClaimed.get() && !dismissedThisSession.get();
+ }
+
+ boolean dismissPermanently() {
+ if (!dismissedThisSession.compareAndSet(false, true)) {
+ return false;
+ }
+ persistDismissal.run();
+ return true;
+ }
+}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/views/DeprecationBannerView.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/DeprecationBannerView.java
new file mode 100644
index 000000000..66a453a83
--- /dev/null
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/DeprecationBannerView.java
@@ -0,0 +1,176 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.views;
+
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.ScrolledComposite;
+import org.eclipse.swt.events.SelectionAdapter;
+import org.eclipse.swt.events.SelectionEvent;
+import org.eclipse.swt.graphics.Color;
+import org.eclipse.swt.graphics.Font;
+import org.eclipse.swt.graphics.FontData;
+import org.eclipse.swt.layout.FillLayout;
+import org.eclipse.swt.layout.GridData;
+import org.eclipse.swt.layout.GridLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Link;
+import org.eclipse.ui.ISharedImages;
+import org.eclipse.ui.PlatformUI;
+import org.eclipse.ui.part.ViewPart;
+
+import software.aws.toolkits.eclipse.amazonq.util.Constants;
+import software.aws.toolkits.eclipse.amazonq.util.PluginUtils;
+
+/**
+ * Compact, titleless warning view positioned above the Eclipse editor area.
+ */
+public final class DeprecationBannerView extends ViewPart {
+
+ public static final String ID = "software.aws.toolkits.eclipse.amazonq.views.DeprecationBannerView";
+
+ private Composite parentComposite;
+
+ @Override
+ public void createPartControl(final Composite parent) {
+ parentComposite = parent;
+ if (!DeprecationBannerManager.getInstance().isPresentationActive()) {
+ hideViewAsync(parent.getDisplay());
+ return;
+ }
+
+ Color background = parent.getDisplay().getSystemColor(SWT.COLOR_INFO_BACKGROUND);
+ Color foreground = parent.getDisplay().getSystemColor(SWT.COLOR_INFO_FOREGROUND);
+ parent.setBackground(background);
+ parent.setLayout(new FillLayout());
+
+ ScrolledComposite scrolled = new ScrolledComposite(parent, SWT.V_SCROLL);
+ scrolled.setBackground(background);
+ scrolled.setExpandHorizontal(true);
+ scrolled.setExpandVertical(true);
+
+ Composite content = new Composite(scrolled, SWT.NONE);
+ configureContainer(content, background);
+
+ Label warningIcon = new Label(content, SWT.NONE);
+ warningIcon.setBackground(background);
+ warningIcon.setImage(PlatformUI.getWorkbench().getSharedImages().getImage(ISharedImages.IMG_OBJS_WARN_TSK));
+ warningIcon.setToolTipText("Warning");
+ warningIcon.setLayoutData(new GridData(SWT.BEGINNING, SWT.BEGINNING, false, false));
+
+ Composite textContainer = createTextContainer(content, background);
+ createHeading(textContainer, background, foreground);
+ createMessage(textContainer, background, foreground);
+ createLearnMoreLink(textContainer, background);
+ createDismissButton(content);
+
+ scrolled.setContent(content);
+ updateMinimumSize(scrolled, content);
+ scrolled.addListener(SWT.Resize, event -> updateMinimumSize(scrolled, content));
+ }
+
+ private Composite createTextContainer(final Composite parent, final Color background) {
+ Composite textContainer = new Composite(parent, SWT.NONE);
+ textContainer.setBackground(background);
+ GridLayout textLayout = new GridLayout(1, false);
+ textLayout.marginWidth = 0;
+ textLayout.marginHeight = 0;
+ textLayout.verticalSpacing = 2;
+ textContainer.setLayout(textLayout);
+ textContainer.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
+ return textContainer;
+ }
+
+ private void createHeading(final Composite parent, final Color background,
+ final Color foreground) {
+ Label heading = new Label(parent, SWT.WRAP);
+ heading.setBackground(background);
+ heading.setForeground(foreground);
+ heading.setText(Constants.DEPRECATION_NOTICE_TITLE);
+ FontData[] fontData = heading.getFont().getFontData();
+ for (FontData data : fontData) {
+ data.setStyle(data.getStyle() | SWT.BOLD);
+ }
+ Font headingFont = new Font(parent.getDisplay(), fontData);
+ heading.setFont(headingFont);
+ heading.addDisposeListener(event -> headingFont.dispose());
+ heading.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
+ }
+
+ private void createMessage(final Composite parent, final Color background,
+ final Color foreground) {
+ Link message = new Link(parent, SWT.WRAP);
+ message.setBackground(background);
+ message.setForeground(foreground);
+ message.setText(Constants.DEPRECATION_NOTICE_BODY.replace(
+ "explore Kiro",
+ String.format("explore Kiro", Constants.KIRO_URL)));
+ message.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
+ message.addSelectionListener(new SelectionAdapter() {
+ @Override
+ public void widgetSelected(final SelectionEvent event) {
+ PluginUtils.openWebpage(event.text);
+ }
+ });
+ }
+
+ private void createLearnMoreLink(final Composite parent, final Color background) {
+ Link learnMore = new Link(parent, SWT.NONE);
+ learnMore.setBackground(background);
+ learnMore.setText(String.format("Learn more",
+ Constants.DEPRECATION_NOTICE_LEARN_MORE_URL));
+ learnMore.setLayoutData(new GridData(SWT.BEGINNING, SWT.CENTER, false, false));
+ learnMore.addSelectionListener(new SelectionAdapter() {
+ @Override
+ public void widgetSelected(final SelectionEvent event) {
+ PluginUtils.openWebpage(event.text);
+ }
+ });
+ }
+
+ private void createDismissButton(final Composite parent) {
+ Button dismiss = new Button(parent, SWT.PUSH);
+ dismiss.setText("Don't show again");
+ dismiss.setToolTipText("Permanently dismiss this end-of-support banner");
+ dismiss.setLayoutData(new GridData(SWT.END, SWT.BEGINNING, false, false));
+ dismiss.addSelectionListener(new SelectionAdapter() {
+ @Override
+ public void widgetSelected(final SelectionEvent event) {
+ DeprecationBannerManager.getInstance().dismissPermanently();
+ }
+ });
+ }
+
+ private void configureContainer(final Composite parent, final Color background) {
+ GridLayout layout = new GridLayout(3, false);
+ layout.marginWidth = 10;
+ layout.marginHeight = 6;
+ layout.horizontalSpacing = 8;
+ parent.setLayout(layout);
+ parent.setBackground(background);
+ }
+
+ private void updateMinimumSize(final ScrolledComposite scrolled, final Composite content) {
+ int availableWidth = scrolled.getClientArea().width;
+ int widthHint = availableWidth > 0 ? availableWidth : SWT.DEFAULT;
+ scrolled.setMinSize(content.computeSize(widthHint, SWT.DEFAULT));
+ }
+
+ private void hideViewAsync(final Display display) {
+ display.asyncExec(() -> {
+ if (!display.isDisposed() && getSite() != null) {
+ getSite().getPage().hideView(this);
+ }
+ });
+ }
+
+ @Override
+ public void setFocus() {
+ if (parentComposite != null && !parentComposite.isDisposed()) {
+ parentComposite.setFocus();
+ }
+ }
+}
diff --git a/plugin/tst/software/aws/toolkits/eclipse/amazonq/util/DeprecationNoticeContentTest.java b/plugin/tst/software/aws/toolkits/eclipse/amazonq/util/DeprecationNoticeContentTest.java
new file mode 100644
index 000000000..b4ae97574
--- /dev/null
+++ b/plugin/tst/software/aws/toolkits/eclipse/amazonq/util/DeprecationNoticeContentTest.java
@@ -0,0 +1,24 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+public final class DeprecationNoticeContentTest {
+
+ @Test
+ void usesApprovedEditorBannerContentAndLinks() {
+ assertEquals("Amazon Q Developer IDE plugins: end of support",
+ Constants.DEPRECATION_NOTICE_TITLE);
+ assertEquals("On April 30, 2027, AWS will discontinue support for Amazon Q Developer IDE plugins. "
+ + "For capabilities similar to Amazon Q Developer IDE plugins, explore Kiro to access the latest models and features, "
+ + "including agentic coding, chat and MCP support.",
+ Constants.DEPRECATION_NOTICE_BODY);
+ assertEquals("https://kiro.dev", Constants.KIRO_URL);
+ assertEquals("https://aws.amazon.com/blogs/devops/amazon-q-developer-end-of-support-announcement/",
+ Constants.DEPRECATION_NOTICE_LEARN_MORE_URL);
+ }
+}
diff --git a/plugin/tst/software/aws/toolkits/eclipse/amazonq/views/DeprecationBannerManagerTest.java b/plugin/tst/software/aws/toolkits/eclipse/amazonq/views/DeprecationBannerManagerTest.java
new file mode 100644
index 000000000..c8cb7cd6a
--- /dev/null
+++ b/plugin/tst/software/aws/toolkits/eclipse/amazonq/views/DeprecationBannerManagerTest.java
@@ -0,0 +1,248 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.views;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.ui.IPageListener;
+import org.eclipse.ui.IPerspectiveListener;
+import org.eclipse.ui.IViewPart;
+import org.eclipse.ui.IWindowListener;
+import org.eclipse.ui.IWorkbench;
+import org.eclipse.ui.IWorkbenchPage;
+import org.eclipse.ui.IWorkbenchWindow;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+public final class DeprecationBannerManagerTest {
+
+ @Test
+ void perspectiveChangesReuseOneLogicalPresentation() throws Exception {
+ IWorkbench workbench = mock(IWorkbench.class);
+ IWorkbenchWindow window = mock(IWorkbenchWindow.class);
+ IWorkbenchPage firstPage = mock(IWorkbenchPage.class);
+ IWorkbenchPage secondPage = mock(IWorkbenchPage.class);
+ when(window.getActivePage()).thenReturn(firstPage);
+ when(workbench.getActiveWorkbenchWindow()).thenReturn(window);
+ when(workbench.getWorkbenchWindows()).thenReturn(new IWorkbenchWindow[] {window});
+
+ DeprecationBannerSession session = newSession(new AtomicInteger());
+ DeprecationBannerManager manager = new DeprecationBannerManager(session, workbench);
+ manager.showOncePerSession();
+
+ ArgumentCaptor perspectiveListener = ArgumentCaptor.forClass(
+ IPerspectiveListener.class);
+ verify(window).addPerspectiveListener(perspectiveListener.capture());
+
+ perspectiveListener.getValue().perspectiveActivated(secondPage, null);
+
+ verify(firstPage).showView(
+ DeprecationBannerView.ID, null, IWorkbenchPage.VIEW_VISIBLE);
+ verify(secondPage).showView(
+ DeprecationBannerView.ID, null, IWorkbenchPage.VIEW_VISIBLE);
+ assertFalse(session.claimPresentation());
+ }
+
+ @Test
+ void waitsForAWorkbenchWindowWithoutReclaimingTheSession() throws Exception {
+ IWorkbench workbench = mock(IWorkbench.class);
+ IWorkbenchWindow window = mock(IWorkbenchWindow.class);
+ IWorkbenchPage page = mock(IWorkbenchPage.class);
+ when(workbench.getWorkbenchWindows()).thenReturn(new IWorkbenchWindow[0]);
+ when(window.getActivePage()).thenReturn(page);
+
+ DeprecationBannerSession session = newSession(new AtomicInteger());
+ DeprecationBannerManager manager = new DeprecationBannerManager(session, workbench);
+ manager.showOncePerSession();
+
+ ArgumentCaptor windowListener = ArgumentCaptor.forClass(
+ IWindowListener.class);
+ verify(workbench).addWindowListener(windowListener.capture());
+ assertTrue(manager.isPresentationActive());
+ assertFalse(session.claimPresentation());
+
+ windowListener.getValue().windowOpened(window);
+
+ verify(page).showView(
+ DeprecationBannerView.ID, null, IWorkbenchPage.VIEW_VISIBLE);
+ verify(window).addPerspectiveListener(any(IPerspectiveListener.class));
+ }
+
+ @Test
+ void waitsForTheExistingWorkbenchWindowPageToBecomeReady() throws Exception {
+ IWorkbench workbench = mock(IWorkbench.class);
+ IWorkbenchWindow window = mock(IWorkbenchWindow.class);
+ IWorkbenchPage page = mock(IWorkbenchPage.class);
+ when(workbench.getActiveWorkbenchWindow()).thenReturn(window);
+ when(workbench.getWorkbenchWindows()).thenReturn(new IWorkbenchWindow[] {window});
+ when(window.getActivePage()).thenReturn(null);
+ when(page.getWorkbenchWindow()).thenReturn(window);
+
+ DeprecationBannerSession session = newSession(new AtomicInteger());
+ DeprecationBannerManager manager = new DeprecationBannerManager(session, workbench);
+ manager.showOncePerSession();
+
+ ArgumentCaptor pageListener = ArgumentCaptor.forClass(
+ IPageListener.class);
+ verify(window).addPageListener(pageListener.capture());
+ assertTrue(manager.isPresentationActive());
+
+ when(window.getActivePage()).thenReturn(page);
+ pageListener.getValue().pageOpened(page);
+
+ verify(page).showView(
+ DeprecationBannerView.ID, null, IWorkbenchPage.VIEW_VISIBLE);
+ verify(window).addPerspectiveListener(any(IPerspectiveListener.class));
+ }
+
+ @Test
+ void retriesWhenInitialWindowAndPageEventsAreMissed() throws Exception {
+ IWorkbench workbench = mock(IWorkbench.class);
+ IWorkbenchWindow window = mock(IWorkbenchWindow.class);
+ IWorkbenchPage page = mock(IWorkbenchPage.class);
+ Display display = mock(Display.class);
+ when(workbench.getWorkbenchWindows()).thenReturn(new IWorkbenchWindow[0]);
+ when(workbench.getDisplay()).thenReturn(display);
+
+ DeprecationBannerManager manager = new DeprecationBannerManager(
+ newSession(new AtomicInteger()), workbench);
+ manager.showOncePerSession();
+
+ ArgumentCaptor retry = ArgumentCaptor.forClass(Runnable.class);
+ verify(display).timerExec(eq(250), retry.capture());
+
+ when(workbench.getActiveWorkbenchWindow()).thenReturn(window);
+ when(workbench.getWorkbenchWindows()).thenReturn(new IWorkbenchWindow[] {window});
+ when(window.getActivePage()).thenReturn(page);
+ retry.getValue().run();
+
+ verify(page).showView(
+ DeprecationBannerView.ID, null, IWorkbenchPage.VIEW_VISIBLE);
+ verify(window).addPerspectiveListener(any(IPerspectiveListener.class));
+ }
+
+ @Test
+ void closingPresentationWindowTransfersToAnotherWindow() throws Exception {
+ IWorkbench workbench = mock(IWorkbench.class);
+ IWorkbenchWindow firstWindow = mock(IWorkbenchWindow.class);
+ IWorkbenchWindow secondWindow = mock(IWorkbenchWindow.class);
+ IWorkbenchPage firstPage = mock(IWorkbenchPage.class);
+ IWorkbenchPage secondPage = mock(IWorkbenchPage.class);
+ when(firstWindow.getActivePage()).thenReturn(firstPage);
+ when(secondWindow.getActivePage()).thenReturn(secondPage);
+ when(workbench.getActiveWorkbenchWindow()).thenReturn(firstWindow);
+ when(workbench.getWorkbenchWindows()).thenReturn(
+ new IWorkbenchWindow[] {firstWindow, secondWindow});
+
+ DeprecationBannerManager manager = new DeprecationBannerManager(
+ newSession(new AtomicInteger()), workbench);
+ manager.showOncePerSession();
+
+ ArgumentCaptor windowListener = ArgumentCaptor.forClass(
+ IWindowListener.class);
+ ArgumentCaptor perspectiveListener = ArgumentCaptor.forClass(
+ IPerspectiveListener.class);
+ verify(workbench).addWindowListener(windowListener.capture());
+ verify(firstWindow).addPerspectiveListener(perspectiveListener.capture());
+
+ windowListener.getValue().windowClosed(firstWindow);
+
+ verify(firstWindow).removePerspectiveListener(perspectiveListener.getValue());
+ verify(secondPage).showView(
+ DeprecationBannerView.ID, null, IWorkbenchPage.VIEW_VISIBLE);
+ verify(secondWindow).addPerspectiveListener(perspectiveListener.getValue());
+ }
+
+ @Test
+ void otherWindowEventsDoNotCreateDuplicatePresentation() throws Exception {
+ IWorkbench workbench = mock(IWorkbench.class);
+ IWorkbenchWindow firstWindow = mock(IWorkbenchWindow.class);
+ IWorkbenchWindow secondWindow = mock(IWorkbenchWindow.class);
+ IWorkbenchPage firstPage = mock(IWorkbenchPage.class);
+ IWorkbenchPage secondPage = mock(IWorkbenchPage.class);
+ when(firstWindow.getActivePage()).thenReturn(firstPage);
+ when(secondWindow.getActivePage()).thenReturn(secondPage);
+ when(workbench.getActiveWorkbenchWindow()).thenReturn(firstWindow);
+ when(workbench.getWorkbenchWindows()).thenReturn(
+ new IWorkbenchWindow[] {firstWindow, secondWindow});
+
+ DeprecationBannerManager manager = new DeprecationBannerManager(
+ newSession(new AtomicInteger()), workbench);
+ manager.showOncePerSession();
+
+ ArgumentCaptor windowListener = ArgumentCaptor.forClass(
+ IWindowListener.class);
+ verify(workbench).addWindowListener(windowListener.capture());
+ windowListener.getValue().windowOpened(secondWindow);
+ windowListener.getValue().windowActivated(secondWindow);
+
+ verify(firstPage, times(1)).showView(
+ DeprecationBannerView.ID, null, IWorkbenchPage.VIEW_VISIBLE);
+ verify(secondPage, never()).showView(
+ DeprecationBannerView.ID, null, IWorkbenchPage.VIEW_VISIBLE);
+ }
+
+ @Test
+ void permanentDismissalPersistsAndCleansUpPresentationState() throws Exception {
+ IWorkbench workbench = mock(IWorkbench.class);
+ IWorkbenchWindow window = mock(IWorkbenchWindow.class);
+ IWorkbenchPage page = mock(IWorkbenchPage.class);
+ IViewPart view = mock(IViewPart.class);
+ Display display = mock(Display.class);
+ when(window.getActivePage()).thenReturn(page);
+ when(window.getPages()).thenReturn(new IWorkbenchPage[] {page});
+ when(page.findView(DeprecationBannerView.ID)).thenReturn(view);
+ when(workbench.getActiveWorkbenchWindow()).thenReturn(window);
+ when(workbench.getWorkbenchWindows()).thenReturn(new IWorkbenchWindow[] {window});
+ when(workbench.getDisplay()).thenReturn(display);
+ when(display.getThread()).thenReturn(Thread.currentThread());
+
+ AtomicBoolean dismissed = new AtomicBoolean();
+ AtomicInteger persistenceCalls = new AtomicInteger();
+ DeprecationBannerSession session = new DeprecationBannerSession(
+ dismissed::get,
+ () -> {
+ dismissed.set(true);
+ persistenceCalls.incrementAndGet();
+ });
+ DeprecationBannerManager manager = new DeprecationBannerManager(session, workbench);
+ manager.showOncePerSession();
+
+ ArgumentCaptor windowListener = ArgumentCaptor.forClass(
+ IWindowListener.class);
+ ArgumentCaptor perspectiveListener = ArgumentCaptor.forClass(
+ IPerspectiveListener.class);
+ verify(workbench).addWindowListener(windowListener.capture());
+ verify(window).addPerspectiveListener(perspectiveListener.capture());
+
+ manager.dismissPermanently();
+ manager.dismissPermanently();
+
+ verify(page).hideView(view);
+ verify(window).removePerspectiveListener(perspectiveListener.getValue());
+ verify(workbench).removeWindowListener(windowListener.getValue());
+ assertFalse(manager.isPresentationActive());
+ assertTrue(dismissed.get());
+ assertEquals(1, persistenceCalls.get());
+ }
+
+ private DeprecationBannerSession newSession(final AtomicInteger persistenceCalls) {
+ return new DeprecationBannerSession(
+ () -> false,
+ persistenceCalls::incrementAndGet);
+ }
+}
diff --git a/plugin/tst/software/aws/toolkits/eclipse/amazonq/views/DeprecationBannerSessionTest.java b/plugin/tst/software/aws/toolkits/eclipse/amazonq/views/DeprecationBannerSessionTest.java
new file mode 100644
index 000000000..68405321d
--- /dev/null
+++ b/plugin/tst/software/aws/toolkits/eclipse/amazonq/views/DeprecationBannerSessionTest.java
@@ -0,0 +1,54 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.views;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+
+public final class DeprecationBannerSessionTest {
+
+ @Test
+ void permanentlyDismissedBannerIsNotPresented() {
+ var session = new DeprecationBannerSession(() -> true, () -> {
+ });
+
+ assertFalse(session.claimPresentation());
+ assertFalse(session.isPresentationActive());
+ }
+
+ @Test
+ void onlyOneWindowCanClaimTheLogicalPresentation() {
+ var session = new DeprecationBannerSession(() -> false, () -> {
+ });
+
+ assertTrue(session.claimPresentation());
+ assertFalse(session.claimPresentation());
+ assertTrue(session.isPresentationActive());
+ }
+
+ @Test
+ void explicitDismissalIsPersistedOnlyOnce() {
+ AtomicBoolean permanentlyDismissed = new AtomicBoolean();
+ AtomicInteger persistenceCalls = new AtomicInteger();
+ var session = new DeprecationBannerSession(
+ permanentlyDismissed::get,
+ () -> {
+ permanentlyDismissed.set(true);
+ persistenceCalls.incrementAndGet();
+ });
+ session.claimPresentation();
+
+ assertTrue(session.dismissPermanently());
+ assertFalse(session.dismissPermanently());
+ assertFalse(session.isPresentationActive());
+ assertTrue(permanentlyDismissed.get());
+ assertEquals(1, persistenceCalls.get());
+ }
+}
From 0f10e99f6f90d08294e84f3f1601efeb75bc1996 Mon Sep 17 00:00:00 2001
From: araarush
Date: Tue, 29 Sep 2026 15:10:34 -0700
Subject: [PATCH 4/4] Persist chat deprecation acknowledgement (#580)
Co-authored-by: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com>
---
.../DeprecationAcknowledgmentStore.java | 107 ++++++++++++++++++
.../configuration/PluginStoreKeys.java | 1 +
.../assets/ChatClientConfiguration.java | 39 +++++++
.../assets/ChatWebViewAssetProvider.java | 12 +-
.../views/AmazonQChatViewActionHandler.java | 14 ++-
.../ChatPromptAcknowledgmentHandler.java | 32 ++++++
.../DeprecationAcknowledgmentStoreTest.java | 100 ++++++++++++++++
.../assets/ChatClientConfigurationTest.java | 82 ++++++++++++++
.../ChatPromptAcknowledgmentHandlerTest.java | 97 ++++++++++++++++
9 files changed, 473 insertions(+), 11 deletions(-)
create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/configuration/DeprecationAcknowledgmentStore.java
create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/providers/assets/ChatClientConfiguration.java
create mode 100644 plugin/src/software/aws/toolkits/eclipse/amazonq/views/ChatPromptAcknowledgmentHandler.java
create mode 100644 plugin/tst/software/aws/toolkits/eclipse/amazonq/configuration/DeprecationAcknowledgmentStoreTest.java
create mode 100644 plugin/tst/software/aws/toolkits/eclipse/amazonq/providers/assets/ChatClientConfigurationTest.java
create mode 100644 plugin/tst/software/aws/toolkits/eclipse/amazonq/views/ChatPromptAcknowledgmentHandlerTest.java
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/configuration/DeprecationAcknowledgmentStore.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/configuration/DeprecationAcknowledgmentStore.java
new file mode 100644
index 000000000..8d403a207
--- /dev/null
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/configuration/DeprecationAcknowledgmentStore.java
@@ -0,0 +1,107 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.configuration;
+
+import org.eclipse.core.runtime.preferences.ConfigurationScope;
+import org.eclipse.core.runtime.preferences.IEclipsePreferences;
+import org.osgi.service.prefs.BackingStoreException;
+
+import software.aws.toolkits.eclipse.amazonq.plugin.Activator;
+import software.aws.toolkits.eclipse.amazonq.util.LoggingService;
+
+/**
+ * Persists deprecation acknowledgements at the Eclipse installation level when possible.
+ *
+ * Some Eclipse installations do not allow writes to the configuration scope. In those
+ * environments, acknowledgements fall back to the existing workspace-scoped plugin store.
+ */
+public final class DeprecationAcknowledgmentStore {
+
+ private static final String STORAGE_PROBE_KEY = "deprecationAcknowledgmentStorageProbe";
+
+ private final IEclipsePreferences installationPreferences;
+ private final PluginStore workspaceFallback;
+ private final LoggingService logger;
+
+ private boolean storageChecked;
+ private boolean useWorkspaceFallback;
+
+ public DeprecationAcknowledgmentStore(final IEclipsePreferences installationPreferences,
+ final PluginStore workspaceFallback, final LoggingService logger) {
+ this.installationPreferences = installationPreferences;
+ this.workspaceFallback = workspaceFallback;
+ this.logger = logger;
+ }
+
+ public static DeprecationAcknowledgmentStore getInstance() {
+ return InstanceHolder.INSTANCE;
+ }
+
+ public synchronized boolean isAcknowledged(final String key) {
+ ensureStorageAvailable();
+ if (useWorkspaceFallback) {
+ return "true".equals(workspaceFallback.get(key));
+ }
+ try {
+ return "true".equals(installationPreferences.get(key, null));
+ } catch (RuntimeException e) {
+ switchToWorkspaceFallback("reading", key, e);
+ return "true".equals(workspaceFallback.get(key));
+ }
+ }
+
+ public synchronized void acknowledge(final String key) {
+ ensureStorageAvailable();
+ if (useWorkspaceFallback) {
+ workspaceFallback.put(key, "true");
+ return;
+ }
+ try {
+ installationPreferences.put(key, "true");
+ installationPreferences.flush();
+ } catch (BackingStoreException | RuntimeException e) {
+ switchToWorkspaceFallback("saving", key, e);
+ workspaceFallback.put(key, "true");
+ }
+ }
+
+ private void ensureStorageAvailable() {
+ if (storageChecked) {
+ return;
+ }
+ storageChecked = true;
+ try {
+ installationPreferences.put(STORAGE_PROBE_KEY, "true");
+ installationPreferences.flush();
+ installationPreferences.remove(STORAGE_PROBE_KEY);
+ installationPreferences.flush();
+ } catch (BackingStoreException | RuntimeException e) {
+ try {
+ installationPreferences.remove(STORAGE_PROBE_KEY);
+ } catch (RuntimeException ignored) {
+ // The installation scope is already known to be unavailable.
+ }
+ switchToWorkspaceFallback("validating", STORAGE_PROBE_KEY, e);
+ }
+ }
+
+ private void switchToWorkspaceFallback(final String operation, final String key, final Exception exception) {
+ useWorkspaceFallback = true;
+ if (logger != null) {
+ logger.warn(String.format(
+ "Unable to use installation-level preferences while %s deprecation acknowledgement '%s'; using workspace preferences",
+ operation, key), exception);
+ }
+ }
+
+ private static final class InstanceHolder {
+ private static final DeprecationAcknowledgmentStore INSTANCE = new DeprecationAcknowledgmentStore(
+ ConfigurationScope.INSTANCE.getNode(Activator.PLUGIN_ID),
+ Activator.getPluginStore(),
+ Activator.getLogger());
+
+ private InstanceHolder() {
+ }
+ }
+}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/configuration/PluginStoreKeys.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/configuration/PluginStoreKeys.java
index 8b3638e0d..cba7e4370 100644
--- a/plugin/src/software/aws/toolkits/eclipse/amazonq/configuration/PluginStoreKeys.java
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/configuration/PluginStoreKeys.java
@@ -11,6 +11,7 @@ private PluginStoreKeys() {
public static final String CHAT_DISCLAIMER_ACKNOWLEDGED = "qchatDisclaimerAcknowledged";
public static final String PAIR_PROGRAMMING_ACKNOWLEDGED = "qchatPairProgrammingAcknowledged";
+ public static final String CHAT_DEPRECATION_NOTICE_ACKNOWLEDGED = "qchatDeprecationNoticeAcknowledged";
public static final String EDITOR_DEPRECATION_BANNER_DISMISSED = "editorDeprecationBannerDismissed";
}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/providers/assets/ChatClientConfiguration.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/providers/assets/ChatClientConfiguration.java
new file mode 100644
index 000000000..681eb469a
--- /dev/null
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/providers/assets/ChatClientConfiguration.java
@@ -0,0 +1,39 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.providers.assets;
+
+import software.aws.toolkits.eclipse.amazonq.configuration.DeprecationAcknowledgmentStore;
+import software.aws.toolkits.eclipse.amazonq.configuration.PluginStore;
+import software.aws.toolkits.eclipse.amazonq.configuration.PluginStoreKeys;
+
+final class ChatClientConfiguration {
+
+ private final boolean disclaimerAcknowledged;
+ private final boolean pairProgrammingAcknowledged;
+ private final boolean deprecationNoticeAcknowledged;
+
+ private ChatClientConfiguration(final boolean disclaimerAcknowledged,
+ final boolean pairProgrammingAcknowledged, final boolean deprecationNoticeAcknowledged) {
+ this.disclaimerAcknowledged = disclaimerAcknowledged;
+ this.pairProgrammingAcknowledged = pairProgrammingAcknowledged;
+ this.deprecationNoticeAcknowledged = deprecationNoticeAcknowledged;
+ }
+
+ static ChatClientConfiguration load(final PluginStore pluginStore,
+ final DeprecationAcknowledgmentStore deprecationAcknowledgmentStore) {
+ return new ChatClientConfiguration(
+ "true".equals(pluginStore.get(PluginStoreKeys.CHAT_DISCLAIMER_ACKNOWLEDGED)),
+ "true".equals(pluginStore.get(PluginStoreKeys.PAIR_PROGRAMMING_ACKNOWLEDGED)),
+ deprecationAcknowledgmentStore.isAcknowledged(
+ PluginStoreKeys.CHAT_DEPRECATION_NOTICE_ACKNOWLEDGED));
+ }
+
+ String toJavaScript() {
+ return String.format("""
+ disclaimerAcknowledged: %b,
+ pairProgrammingAcknowledged: %b,
+ deprecationNoticeAcknowledged: %b,
+ """, disclaimerAcknowledged, pairProgrammingAcknowledged, deprecationNoticeAcknowledged);
+ }
+}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/providers/assets/ChatWebViewAssetProvider.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/providers/assets/ChatWebViewAssetProvider.java
index 3b7f2905e..b142a3e23 100644
--- a/plugin/src/software/aws/toolkits/eclipse/amazonq/providers/assets/ChatWebViewAssetProvider.java
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/providers/assets/ChatWebViewAssetProvider.java
@@ -16,7 +16,7 @@
import software.aws.toolkits.eclipse.amazonq.broker.events.ChatWebViewAssetState;
import software.aws.toolkits.eclipse.amazonq.chat.ChatCommunicationManager;
import software.aws.toolkits.eclipse.amazonq.chat.ChatTheme;
-import software.aws.toolkits.eclipse.amazonq.configuration.PluginStoreKeys;
+import software.aws.toolkits.eclipse.amazonq.configuration.DeprecationAcknowledgmentStore;
import software.aws.toolkits.eclipse.amazonq.plugin.Activator;
import software.aws.toolkits.eclipse.amazonq.providers.lsp.LspManagerProvider;
import software.aws.toolkits.eclipse.amazonq.util.PluginPlatform;
@@ -187,8 +187,8 @@ private String getEclipseWebkitScript() {
}
private String generateJS(final String jsEntrypoint) {
- var disclaimerAcknowledged = Activator.getPluginStore().get(PluginStoreKeys.CHAT_DISCLAIMER_ACKNOWLEDGED);
- var pairProgrammingAcknowledged = Activator.getPluginStore().get(PluginStoreKeys.PAIR_PROGRAMMING_ACKNOWLEDGED);
+ var clientConfiguration = ChatClientConfiguration.load(
+ Activator.getPluginStore(), DeprecationAcknowledgmentStore.getInstance());
return String.format("""
- """, jsEntrypoint, getWaitFunction(), "true".equals(disclaimerAcknowledged), "true".equals(pairProgrammingAcknowledged),
- getInputFunctions());
+ """, jsEntrypoint, getWaitFunction(), clientConfiguration.toJavaScript(), getInputFunctions());
}
@SuppressWarnings("MethodLength")
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/views/AmazonQChatViewActionHandler.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/AmazonQChatViewActionHandler.java
index 7e383652b..d494072c5 100644
--- a/plugin/src/software/aws/toolkits/eclipse/amazonq/views/AmazonQChatViewActionHandler.java
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/AmazonQChatViewActionHandler.java
@@ -23,6 +23,7 @@
import software.aws.toolkits.eclipse.amazonq.chat.ChatCommunicationManager;
import software.aws.toolkits.eclipse.amazonq.chat.ChatMessage;
import software.aws.toolkits.eclipse.amazonq.chat.models.CursorState;
+import software.aws.toolkits.eclipse.amazonq.configuration.DeprecationAcknowledgmentStore;
import software.aws.toolkits.eclipse.amazonq.configuration.PluginStoreKeys;
import software.aws.toolkits.eclipse.amazonq.exception.AmazonQPluginException;
import software.aws.toolkits.eclipse.amazonq.lsp.auth.model.AuthFollowUpType;
@@ -36,9 +37,17 @@
public class AmazonQChatViewActionHandler implements ViewActionHandler {
private ChatCommunicationManager chatCommunicationManager;
+ private final ChatPromptAcknowledgmentHandler promptAcknowledgmentHandler;
public AmazonQChatViewActionHandler(final ChatCommunicationManager chatCommunicationManager) {
+ this(chatCommunicationManager, new ChatPromptAcknowledgmentHandler(
+ Activator.getPluginStore(), DeprecationAcknowledgmentStore.getInstance()));
+ }
+
+ AmazonQChatViewActionHandler(final ChatCommunicationManager chatCommunicationManager,
+ final ChatPromptAcknowledgmentHandler promptAcknowledgmentHandler) {
this.chatCommunicationManager = chatCommunicationManager;
+ this.promptAcknowledgmentHandler = promptAcknowledgmentHandler;
}
/*
@@ -120,10 +129,7 @@ public final void handleCommand(final ParsedCommand parsedCommand, final Browser
@SuppressWarnings("unchecked")
Map options = (Map) message.getData();
String messageId = options.get("messageId");
-
- if ("programmerModeCardId".equals(messageId)) {
- Activator.getPluginStore().put(PluginStoreKeys.PAIR_PROGRAMMING_ACKNOWLEDGED, "true");
- }
+ promptAcknowledgmentHandler.acknowledge(messageId);
break;
case GET_SERIALIZED_CHAT:
ChatAsyncResultManager.getInstance().setResult(parsedCommand.getRequestId(), message.getData());
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/views/ChatPromptAcknowledgmentHandler.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/ChatPromptAcknowledgmentHandler.java
new file mode 100644
index 000000000..da520bed3
--- /dev/null
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/views/ChatPromptAcknowledgmentHandler.java
@@ -0,0 +1,32 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.views;
+
+import software.aws.toolkits.eclipse.amazonq.configuration.DeprecationAcknowledgmentStore;
+import software.aws.toolkits.eclipse.amazonq.configuration.PluginStore;
+import software.aws.toolkits.eclipse.amazonq.configuration.PluginStoreKeys;
+
+final class ChatPromptAcknowledgmentHandler {
+
+ static final String PAIR_PROGRAMMING_MESSAGE_ID = "programmerModeCardId";
+ static final String DEPRECATION_NOTICE_MESSAGE_ID = "client-deprecation-notice";
+
+ private final PluginStore pluginStore;
+ private final DeprecationAcknowledgmentStore deprecationAcknowledgmentStore;
+
+ ChatPromptAcknowledgmentHandler(final PluginStore pluginStore,
+ final DeprecationAcknowledgmentStore deprecationAcknowledgmentStore) {
+ this.pluginStore = pluginStore;
+ this.deprecationAcknowledgmentStore = deprecationAcknowledgmentStore;
+ }
+
+ void acknowledge(final String messageId) {
+ if (PAIR_PROGRAMMING_MESSAGE_ID.equals(messageId)) {
+ pluginStore.put(PluginStoreKeys.PAIR_PROGRAMMING_ACKNOWLEDGED, "true");
+ } else if (DEPRECATION_NOTICE_MESSAGE_ID.equals(messageId)) {
+ deprecationAcknowledgmentStore.acknowledge(
+ PluginStoreKeys.CHAT_DEPRECATION_NOTICE_ACKNOWLEDGED);
+ }
+ }
+}
diff --git a/plugin/tst/software/aws/toolkits/eclipse/amazonq/configuration/DeprecationAcknowledgmentStoreTest.java b/plugin/tst/software/aws/toolkits/eclipse/amazonq/configuration/DeprecationAcknowledgmentStoreTest.java
new file mode 100644
index 000000000..3edf64584
--- /dev/null
+++ b/plugin/tst/software/aws/toolkits/eclipse/amazonq/configuration/DeprecationAcknowledgmentStoreTest.java
@@ -0,0 +1,100 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.configuration;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.eclipse.core.internal.preferences.EclipsePreferences;
+import org.eclipse.core.runtime.preferences.IEclipsePreferences.IPreferenceChangeListener;
+import org.junit.jupiter.api.Test;
+import org.osgi.service.prefs.BackingStoreException;
+
+public final class DeprecationAcknowledgmentStoreTest {
+
+ private static final String TEST_KEY = "testAcknowledgement";
+
+ @Test
+ void storesAcknowledgementInInstallationPreferences() {
+ var installationPreferences = new EclipsePreferences();
+ var workspaceFallback = new InMemoryPluginStore();
+ var store = new DeprecationAcknowledgmentStore(
+ installationPreferences, workspaceFallback, null);
+
+ store.acknowledge(TEST_KEY);
+
+ assertTrue(store.isAcknowledged(TEST_KEY));
+ assertEquals("true", installationPreferences.get(TEST_KEY, null));
+ assertFalse(workspaceFallback.contains(TEST_KEY));
+ }
+
+ @Test
+ void readsUnacknowledgedInstallationPreference() {
+ var store = new DeprecationAcknowledgmentStore(
+ new EclipsePreferences(), new InMemoryPluginStore(), null);
+
+ assertFalse(store.isAcknowledged(TEST_KEY));
+ }
+
+ @Test
+ void fallsBackToWorkspaceWhenInstallationPreferencesAreUnavailable() {
+ var workspaceFallback = new InMemoryPluginStore();
+ workspaceFallback.put(TEST_KEY, "true");
+ var store = new DeprecationAcknowledgmentStore(
+ new UnwritablePreferences(), workspaceFallback, null);
+
+ assertTrue(store.isAcknowledged(TEST_KEY));
+
+ store.acknowledge("anotherKey");
+ assertEquals("true", workspaceFallback.get("anotherKey"));
+ }
+
+ private static final class UnwritablePreferences extends EclipsePreferences {
+ @Override
+ public void flush() throws BackingStoreException {
+ throw new BackingStoreException("configuration scope is read-only");
+ }
+ }
+
+ private static final class InMemoryPluginStore implements PluginStore {
+ private final Map values = new HashMap<>();
+
+ @Override
+ public void put(final String key, final String value) {
+ values.put(key, value);
+ }
+
+ @Override
+ public String get(final String key) {
+ return values.get(key);
+ }
+
+ @Override
+ public void remove(final String key) {
+ values.remove(key);
+ }
+
+ @Override
+ public void addChangeListener(final IPreferenceChangeListener prefChangeListener) {
+ }
+
+ @Override
+ public void putObject(final String key, final T value) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public T getObject(final String key, final Class type) {
+ throw new UnsupportedOperationException();
+ }
+
+ boolean contains(final String key) {
+ return values.containsKey(key);
+ }
+ }
+}
diff --git a/plugin/tst/software/aws/toolkits/eclipse/amazonq/providers/assets/ChatClientConfigurationTest.java b/plugin/tst/software/aws/toolkits/eclipse/amazonq/providers/assets/ChatClientConfigurationTest.java
new file mode 100644
index 000000000..c28fb6aef
--- /dev/null
+++ b/plugin/tst/software/aws/toolkits/eclipse/amazonq/providers/assets/ChatClientConfigurationTest.java
@@ -0,0 +1,82 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.providers.assets;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.eclipse.core.internal.preferences.EclipsePreferences;
+import org.eclipse.core.runtime.preferences.IEclipsePreferences.IPreferenceChangeListener;
+import org.junit.jupiter.api.Test;
+
+import software.aws.toolkits.eclipse.amazonq.configuration.DeprecationAcknowledgmentStore;
+import software.aws.toolkits.eclipse.amazonq.configuration.PluginStore;
+import software.aws.toolkits.eclipse.amazonq.configuration.PluginStoreKeys;
+
+public final class ChatClientConfigurationTest {
+
+ @Test
+ void forwardsAcknowledgedStateForLegacyAndDeprecationPrompts() {
+ var pluginStore = new InMemoryPluginStore();
+ pluginStore.put(PluginStoreKeys.CHAT_DISCLAIMER_ACKNOWLEDGED, "true");
+ pluginStore.put(PluginStoreKeys.PAIR_PROGRAMMING_ACKNOWLEDGED, "true");
+ var deprecationStore = new DeprecationAcknowledgmentStore(
+ new EclipsePreferences(), pluginStore, null);
+ deprecationStore.acknowledge(PluginStoreKeys.CHAT_DEPRECATION_NOTICE_ACKNOWLEDGED);
+
+ String javaScript = ChatClientConfiguration.load(pluginStore, deprecationStore)
+ .toJavaScript();
+
+ assertTrue(javaScript.contains("disclaimerAcknowledged: true"));
+ assertTrue(javaScript.contains("pairProgrammingAcknowledged: true"));
+ assertTrue(javaScript.contains("deprecationNoticeAcknowledged: true"));
+ }
+
+ @Test
+ void forwardsFalseForUnacknowledgedDeprecationPrompt() {
+ var pluginStore = new InMemoryPluginStore();
+ var deprecationStore = new DeprecationAcknowledgmentStore(
+ new EclipsePreferences(), pluginStore, null);
+
+ String javaScript = ChatClientConfiguration.load(pluginStore, deprecationStore)
+ .toJavaScript();
+
+ assertTrue(javaScript.contains("deprecationNoticeAcknowledged: false"));
+ }
+
+ private static final class InMemoryPluginStore implements PluginStore {
+ private final Map values = new HashMap<>();
+
+ @Override
+ public void put(final String key, final String value) {
+ values.put(key, value);
+ }
+
+ @Override
+ public String get(final String key) {
+ return values.get(key);
+ }
+
+ @Override
+ public void remove(final String key) {
+ values.remove(key);
+ }
+
+ @Override
+ public void addChangeListener(final IPreferenceChangeListener prefChangeListener) {
+ }
+
+ @Override
+ public void putObject(final String key, final T value) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public T getObject(final String key, final Class type) {
+ throw new UnsupportedOperationException();
+ }
+ }
+}
diff --git a/plugin/tst/software/aws/toolkits/eclipse/amazonq/views/ChatPromptAcknowledgmentHandlerTest.java b/plugin/tst/software/aws/toolkits/eclipse/amazonq/views/ChatPromptAcknowledgmentHandlerTest.java
new file mode 100644
index 000000000..f4fddc433
--- /dev/null
+++ b/plugin/tst/software/aws/toolkits/eclipse/amazonq/views/ChatPromptAcknowledgmentHandlerTest.java
@@ -0,0 +1,97 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.views;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.eclipse.core.internal.preferences.EclipsePreferences;
+import org.eclipse.core.runtime.preferences.IEclipsePreferences.IPreferenceChangeListener;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import software.aws.toolkits.eclipse.amazonq.configuration.DeprecationAcknowledgmentStore;
+import software.aws.toolkits.eclipse.amazonq.configuration.PluginStore;
+import software.aws.toolkits.eclipse.amazonq.configuration.PluginStoreKeys;
+
+public final class ChatPromptAcknowledgmentHandlerTest {
+
+ private InMemoryPluginStore pluginStore;
+ private DeprecationAcknowledgmentStore deprecationStore;
+ private ChatPromptAcknowledgmentHandler handler;
+
+ @BeforeEach
+ void setUp() {
+ pluginStore = new InMemoryPluginStore();
+ deprecationStore = new DeprecationAcknowledgmentStore(
+ new EclipsePreferences(), pluginStore, null);
+ handler = new ChatPromptAcknowledgmentHandler(pluginStore, deprecationStore);
+ }
+
+ @Test
+ void persistsDeprecationNoticeAcknowledgement() {
+ handler.acknowledge(ChatPromptAcknowledgmentHandler.DEPRECATION_NOTICE_MESSAGE_ID);
+
+ assertTrue(deprecationStore.isAcknowledged(
+ PluginStoreKeys.CHAT_DEPRECATION_NOTICE_ACKNOWLEDGED));
+ }
+
+ @Test
+ void retainsPairProgrammingAcknowledgementHandling() {
+ handler.acknowledge(ChatPromptAcknowledgmentHandler.PAIR_PROGRAMMING_MESSAGE_ID);
+
+ assertEquals("true", pluginStore.get(PluginStoreKeys.PAIR_PROGRAMMING_ACKNOWLEDGED));
+ }
+
+ @Test
+ void ignoresUnknownAndMissingMessageIds() {
+ handler.acknowledge("unknown");
+ handler.acknowledge(null);
+
+ assertFalse(pluginStore.contains(PluginStoreKeys.PAIR_PROGRAMMING_ACKNOWLEDGED));
+ assertFalse(deprecationStore.isAcknowledged(
+ PluginStoreKeys.CHAT_DEPRECATION_NOTICE_ACKNOWLEDGED));
+ }
+
+ private static final class InMemoryPluginStore implements PluginStore {
+ private final Map values = new HashMap<>();
+
+ @Override
+ public void put(final String key, final String value) {
+ values.put(key, value);
+ }
+
+ @Override
+ public String get(final String key) {
+ return values.get(key);
+ }
+
+ @Override
+ public void remove(final String key) {
+ values.remove(key);
+ }
+
+ @Override
+ public void addChangeListener(final IPreferenceChangeListener prefChangeListener) {
+ }
+
+ @Override
+ public void putObject(final String key, final T value) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public T getObject(final String key, final Class type) {
+ throw new UnsupportedOperationException();
+ }
+
+ boolean contains(final String key) {
+ return values.containsKey(key);
+ }
+ }
+}