diff --git a/plugin/pom.xml b/plugin/pom.xml
index 4d3877b6c..7381a4c3d 100644
--- a/plugin/pom.xml
+++ b/plugin/pom.xml
@@ -6,7 +6,7 @@
software.aws.toolkits.eclipse
amazon-q-eclipse-group
- 2.7.5-SNAPSHOT
+ 2.7.6-SNAPSHOT
../
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/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 5787ad11c..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,5 +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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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);
+ }
+ }
+}
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());
+ }
+}
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);
diff --git a/pom.xml b/pom.xml
index 88eec8919..eac9bc864 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
software.aws.toolkits.eclipse
amazon-q-eclipse-group
- 2.7.5-SNAPSHOT
+ 2.7.6-SNAPSHOT
pom
diff --git a/telemetry/pom.xml b/telemetry/pom.xml
index 661ec38f3..b51e74658 100644
--- a/telemetry/pom.xml
+++ b/telemetry/pom.xml
@@ -6,7 +6,7 @@
software.aws.toolkits.eclipse
amazon-q-eclipse-group
- 2.7.5-SNAPSHOT
+ 2.7.6-SNAPSHOT
../
diff --git a/updatesite/category.xml b/updatesite/category.xml
index 3ac09a107..45696211f 100644
--- a/updatesite/category.xml
+++ b/updatesite/category.xml
@@ -1,6 +1,6 @@
-
+
diff --git a/updatesite/pom.xml b/updatesite/pom.xml
index a35df53ac..b59f3dd13 100644
--- a/updatesite/pom.xml
+++ b/updatesite/pom.xml
@@ -6,7 +6,7 @@
software.aws.toolkits.eclipse
amazon-q-eclipse-group
- 2.7.5-SNAPSHOT
+ 2.7.6-SNAPSHOT
../