elements = value.elements();
+ while (elements.hasNext()) {
+ expressions.add(toExpression(parser, elements.next()));
+ }
+ return expressions;
+ }
+
+ private NotificationExpression toExpression(final JsonParser parser, final JsonNode value) throws IOException {
+ return parser.getCodec().treeToValue(value, NotificationExpression.class);
+ }
+}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationPollingService.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationPollingService.java
new file mode 100644
index 000000000..853f3f766
--- /dev/null
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationPollingService.java
@@ -0,0 +1,179 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.notifications;
+
+import java.time.Duration;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ScheduledFuture;
+import java.util.function.BiFunction;
+import java.util.function.Supplier;
+
+import software.aws.toolkits.eclipse.amazonq.plugin.Activator;
+import software.aws.toolkits.eclipse.amazonq.util.ThreadingUtils;
+
+/**
+ * App-level singleton that polls the notifications endpoint every 10 minutes on the shared worker pool,
+ * self-rescheduling after each poll. The poll body is total (fetch never throws) and the re-arm happens in a
+ * {@code finally}.
+ *
+ * Lifecycle: {@link #start()} is called once at startup; {@link #shutdown()} must be called early in
+ * {@code Activator.stop()} to permanently cancel polling during teardown. {@link #onEnabledPreferenceChanged()} lets
+ * the notifications kill-switch pause/resume polling within a session without an IDE restart.
+ *
+ *
The scheduler and collaborators are injectable via a package-private constructor so unit tests can drive
+ * start/stop/reschedule deterministically without SWT, the network, or a real thread pool.
+ */
+public final class NotificationPollingService {
+
+ private static final NotificationPollingService INSTANCE = new NotificationPollingService();
+ private static final long POLL_INTERVAL_MS = Duration.ofMinutes(10).toMillis();
+
+ /** Abstracts the scheduler so tests can inject a deterministic one; returns a cancellable handle or null. */
+ interface PollScheduler {
+ ScheduledFuture> schedule(Runnable task, long delayMs);
+ }
+
+ private final Supplier enabledSupplier;
+ private final Supplier devBuildSupplier;
+ private final Supplier endpointOverrideSupplier;
+ private final Supplier fetcherSupplier;
+ private final Supplier processorSupplier;
+ private final PollScheduler scheduler;
+
+ private volatile boolean shutdown;
+ private volatile boolean running;
+ private volatile ScheduledFuture> scheduledPoll;
+ private volatile NotificationsFetcher fetcher;
+ private volatile ProcessNotifications processor;
+
+ private NotificationPollingService() {
+ this(
+ NotificationPreferences::isNotificationsEnabled,
+ SystemDetailsCollector::isDevBuild,
+ NotificationPreferences::hasEndpointOverride,
+ () -> new NotificationsFetcher(NotificationPreferences.resolveEndpoint()),
+ () -> new ProcessNotifications(new NotificationDismissalStore()),
+ defaultScheduler());
+ }
+
+ // Package-private for tests.
+ NotificationPollingService(final Supplier enabledSupplier, final Supplier devBuildSupplier,
+ final Supplier endpointOverrideSupplier, final Supplier fetcherSupplier,
+ final Supplier processorSupplier, final PollScheduler scheduler) {
+ this.enabledSupplier = enabledSupplier;
+ this.devBuildSupplier = devBuildSupplier;
+ this.endpointOverrideSupplier = endpointOverrideSupplier;
+ this.fetcherSupplier = fetcherSupplier;
+ this.processorSupplier = processorSupplier;
+ this.scheduler = scheduler;
+ }
+
+ private static PollScheduler defaultScheduler() {
+ final BiFunction> sched =
+ (task, delay) -> (ScheduledFuture>) ThreadingUtils.scheduleAsyncTaskWithDelay(task, delay);
+ return (task, delayMs) -> {
+ try {
+ return sched.apply(task, delayMs);
+ } catch (RejectedExecutionException e) {
+ Activator.getLogger().info("Notifications polling stopped (worker pool shutting down)");
+ return null;
+ }
+ };
+ }
+
+ public static NotificationPollingService getInstance() {
+ return INSTANCE;
+ }
+
+ /** Starts polling once per app lifetime; no-op if disabled, a dev build without override, or already running. */
+ public synchronized void start() {
+ if (shutdown || running) {
+ return;
+ }
+ if (!enabledSupplier.get()) {
+ return;
+ }
+ // Development/unreleased builds must not receive production notifications. Allow an explicit endpoint
+ // override (preference or env var) so local/demo testing against a test endpoint still works.
+ if (devBuildSupplier.get() && !endpointOverrideSupplier.get()) {
+ Activator.getLogger().info("Notifications polling skipped: development build with no endpoint override");
+ return;
+ }
+ this.fetcher = fetcherSupplier.get();
+ this.processor = processorSupplier.get();
+ // Mark running BEFORE scheduling: the first poll is scheduled with delay 0, so on a real thread pool the
+ // poll can execute before this method returns. pollOnce() early-returns unless running==true, so if we set
+ // the flag after scheduling the very first poll can silently no-op (no fetch, no toast, no reschedule).
+ // running is volatile, so the poll thread observes this write. Roll it back if the scheduler rejects.
+ running = true;
+ // Schedule the first poll instead of running it inline so start() never blocks its caller (the shared
+ // startup worker thread) on network I/O.
+ final ScheduledFuture> scheduled = scheduler.schedule(this::pollOnce, 0L);
+ if (scheduled == null) {
+ // The worker pool rejected the task (e.g. shutting down). Reset running so a later re-enable can retry
+ // rather than latching into a started-but-never-scheduled state.
+ running = false;
+ return;
+ }
+ scheduledPoll = scheduled;
+ }
+
+ void pollOnce() {
+ if (shutdown || !running || !enabledSupplier.get()) {
+ return;
+ }
+ try {
+ fetcher.fetch().ifPresent(processor::process);
+ } catch (Throwable t) {
+ Activator.getLogger().warn("Notifications poll failed", t);
+ NotificationTelemetryProvider.emitPollFailure("Failed to poll for notifications");
+ } finally {
+ reschedule();
+ }
+ }
+
+ private synchronized void reschedule() {
+ if (shutdown || !running || !enabledSupplier.get()) {
+ return;
+ }
+ // reschedule() and shutdown() are both synchronized on this monitor, so shutdown cannot interleave here;
+ // the entry guard above plus shutdown()'s cancelPending() are sufficient to stop post-teardown polls.
+ scheduledPoll = scheduler.schedule(this::pollOnce, POLL_INTERVAL_MS);
+ }
+
+ /**
+ * Reacts to a change in the notifications kill-switch preference: starts polling if it was turned on, or pauses
+ * (cancels the pending poll) if it was turned off. Unlike {@link #shutdown()}, this is reversible in-session.
+ */
+ public synchronized void onEnabledPreferenceChanged() {
+ if (shutdown) {
+ return;
+ }
+ if (enabledSupplier.get()) {
+ start();
+ } else {
+ pause();
+ }
+ }
+
+ private synchronized void pause() {
+ running = false;
+ cancelPending();
+ }
+
+ /** Permanently cancels polling for teardown; not resumable. */
+ public synchronized void shutdown() {
+ shutdown = true;
+ running = false;
+ cancelPending();
+ }
+
+ private void cancelPending() {
+ final ScheduledFuture> current = scheduledPoll;
+ if (current != null) {
+ current.cancel(false);
+ scheduledPoll = null;
+ }
+ }
+}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationPreferences.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationPreferences.java
new file mode 100644
index 000000000..399f70fb9
--- /dev/null
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationPreferences.java
@@ -0,0 +1,48 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.notifications;
+
+import software.aws.toolkits.eclipse.amazonq.plugin.Activator;
+import software.aws.toolkits.eclipse.amazonq.preferences.AmazonQPreferencePage;
+
+/** Reads the notifications kill-switch preference and resolves the endpoint (preference > env > prod default). */
+public final class NotificationPreferences {
+
+ private NotificationPreferences() {
+ // prevent instantiation
+ }
+
+ /** Whether the notifications feature is enabled (kill-switch); defaults to {@code true}. */
+ public static boolean isNotificationsEnabled() {
+ return Activator.getDefault().getPreferenceStore().getBoolean(AmazonQPreferencePage.NOTIFICATIONS_OPT_IN);
+ }
+
+ /** Resolves the endpoint URL. Precedence: preference override -> environment variable -> production default. */
+ public static String resolveEndpoint() {
+ final String pref = Activator.getDefault().getPreferenceStore()
+ .getString(AmazonQPreferencePage.NOTIFICATIONS_ENDPOINT_OVERRIDE);
+ if (pref != null && !pref.isBlank()) {
+ return pref;
+ }
+ final String env = System.getenv(NotificationConstants.NOTIFICATIONS_ENDPOINT_ENV);
+ if (env != null && !env.isBlank()) {
+ return env;
+ }
+ return NotificationConstants.NOTIFICATIONS_ENDPOINT;
+ }
+
+ /**
+ * Whether an explicit endpoint override (preference or environment variable) is set. Used to let a
+ * development/PDE build opt in to polling a test endpoint, which is otherwise suppressed on dev builds.
+ */
+ public static boolean hasEndpointOverride() {
+ final String pref = Activator.getDefault().getPreferenceStore()
+ .getString(AmazonQPreferencePage.NOTIFICATIONS_ENDPOINT_OVERRIDE);
+ if (pref != null && !pref.isBlank()) {
+ return true;
+ }
+ final String env = System.getenv(NotificationConstants.NOTIFICATIONS_ENDPOINT_ENV);
+ return env != null && !env.isBlank();
+ }
+}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationTelemetryProvider.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationTelemetryProvider.java
new file mode 100644
index 000000000..2e9f305ee
--- /dev/null
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationTelemetryProvider.java
@@ -0,0 +1,66 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.notifications;
+
+import java.time.Instant;
+
+import software.amazon.awssdk.services.toolkittelemetry.model.MetricDatum;
+import software.aws.toolkits.eclipse.amazonq.plugin.Activator;
+import software.aws.toolkits.telemetry.TelemetryDefinitions.Component;
+import software.aws.toolkits.telemetry.TelemetryDefinitions.Result;
+import software.aws.toolkits.telemetry.ToolkitTelemetry;
+
+/**
+ * Emits notification telemetry ({@code toolkit_showNotification} / {@code toolkit_invokeAction}). Emission routes
+ * through {@code DefaultTelemetryService.emitMetric}, which respects the telemetry opt-in independently of the
+ * notifications feature. The metric {@code id} is the raw notification id (no {@code TARGETED_NOTIFICATION:} prefix).
+ */
+public final class NotificationTelemetryProvider {
+
+ private NotificationTelemetryProvider() {
+ // prevent instantiation
+ }
+
+ /** A notification was shown to the user. */
+ public static void emitShowNotification(final String notificationId) {
+ final MetricDatum datum = ToolkitTelemetry.ShowNotificationEvent()
+ .id(notificationId)
+ .component(Component.INFOBAR)
+ .result(Result.SUCCEEDED)
+ .passive(true)
+ .createTime(Instant.now())
+ .value(1.0)
+ .build();
+ Activator.getTelemetryService().emitMetric(datum);
+ }
+
+ /** A poll cycle failed to retrieve notifications. */
+ public static void emitPollFailure(final String reason) {
+ final MetricDatum datum = ToolkitTelemetry.ShowNotificationEvent()
+ .id("")
+ .component(Component.FILESYSTEM)
+ .result(Result.FAILED)
+ .reason(reason)
+ .passive(true)
+ .createTime(Instant.now())
+ .value(1.0)
+ .build();
+ Activator.getTelemetryService().emitMetric(datum);
+ }
+
+ /** The user clicked an action button on a notification. */
+ public static void emitInvokeAction(final String notificationId, final String actionType) {
+ final MetricDatum datum = ToolkitTelemetry.InvokeActionEvent()
+ .id(notificationId)
+ .source(notificationId)
+ .action(actionType)
+ .component(Component.INFOBAR)
+ .result(Result.SUCCEEDED)
+ .passive(false)
+ .createTime(Instant.now())
+ .value(1.0)
+ .build();
+ Activator.getTelemetryService().emitMetric(datum);
+ }
+}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationsFetcher.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationsFetcher.java
new file mode 100644
index 000000000..83d64dc26
--- /dev/null
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationsFetcher.java
@@ -0,0 +1,215 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.notifications;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.time.Duration;
+import java.util.Optional;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import software.aws.toolkits.eclipse.amazonq.plugin.Activator;
+import software.aws.toolkits.eclipse.amazonq.util.HttpClientFactory;
+import software.aws.toolkits.eclipse.amazonq.util.ObjectMapperFactory;
+import software.aws.toolkits.eclipse.amazonq.util.PluginUtils;
+
+/**
+ * Fetches the hosted notifications payload with ETag conditional GET + on-disk caching, modeled on
+ * {@code VersionManifestFetcher}. {@link #fetch()} is a TOTAL function: any failure (absent file, 403/404, empty body,
+ * malformed JSON, network error) resolves to {@link Optional#empty()} and is only logged — it never throws and never
+ * surfaces a user-facing popup, so a not-yet-deployed endpoint is a silent no-op.
+ */
+public final class NotificationsFetcher {
+
+ // A small hosted JSON over CloudFront returns in well under a second; a 10s timeout bounds how long a poll can
+ // occupy the shared worker thread while still tolerating a slow network. Worst case per poll is bounded to
+ // roughly MAX_RETRIES * TIMEOUT_SECONDS + total backoff.
+ private static final int TIMEOUT_SECONDS = 10;
+ private static final int MAX_RETRIES = 3;
+ private static final long RETRY_BASE_DELAY_MS = 500L;
+ /** Upper bound on the notifications payload size (~1MB of JSON). Real payloads are a few KB. */
+ private static final int MAX_PAYLOAD_CHARS = 1_000_000;
+ private static final ObjectMapper OBJECT_MAPPER = ObjectMapperFactory.getInstance();
+
+ private final String endpointUrl;
+ private final HttpClient httpClient;
+ private final Path cachePath;
+
+ public NotificationsFetcher(final String endpointUrl) {
+ this(endpointUrl, null, null);
+ }
+
+ public NotificationsFetcher(final String endpointUrl, final HttpClient httpClient, final Path cachePath) {
+ // Trim stray whitespace/newlines (a common copy-paste artifact when the endpoint is set via env var / preference).
+ this.endpointUrl = endpointUrl == null ? null : endpointUrl.trim();
+ this.httpClient = httpClient != null ? httpClient : HttpClientFactory.getInstance();
+ this.cachePath = cachePath != null ? cachePath
+ : PluginUtils.getPluginDir(NotificationConstants.NOTIFICATIONS_SUBDIRECTORY)
+ .resolve(NotificationConstants.NOTIFICATIONS_CACHE_FILENAME);
+ }
+
+ /** Never throws. Returns the parsed notifications, or empty when there is nothing to show. */
+ public Optional fetch() {
+ try {
+ if (endpointUrl == null || endpointUrl.isBlank()) {
+ return getResourceFromCache();
+ }
+ if (endpointUrl.regionMatches(true, 0, "file:", 0, 5)) {
+ return readLocalFile(endpointUrl);
+ }
+ return fetchRemoteWithRetries();
+ } catch (Exception e) {
+ Activator.getLogger().warn("Unexpected error fetching notifications", e);
+ return Optional.empty();
+ }
+ }
+
+ private Optional fetchRemoteWithRetries() {
+ final Optional cached = getResourceFromCache();
+ final String cachedEtag = Activator.getPluginStore().get(endpointUrl);
+ final String etagToRequest = cached.isPresent() && cachedEtag != null ? cachedEtag : null;
+
+ Exception lastTransient = null;
+ for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
+ try {
+ final HttpResponse response = getResourceFromRemote(etagToRequest);
+ final int status = response.statusCode();
+
+ if (status == HttpURLConnection.HTTP_NOT_MODIFIED) {
+ if (cached.isPresent()) {
+ return cached;
+ }
+ // ETag stored but cache is gone/invalid: clear it so the next poll re-fetches fresh.
+ Activator.getLogger().warn("Notifications returned 304 but cache is missing; clearing ETag");
+ Activator.getPluginStore().remove(endpointUrl);
+ return Optional.empty();
+ }
+ if (status == HttpURLConnection.HTTP_OK) {
+ return validateAndCache(response);
+ }
+ // 403/404 (file not deployed yet) and any other non-2xx: not an error condition, show nothing.
+ Activator.getLogger().info("No notifications available (HTTP " + status + ")");
+ return Optional.empty();
+ } catch (IOException | InterruptedException e) {
+ if (e instanceof InterruptedException) {
+ Thread.currentThread().interrupt();
+ return cached;
+ }
+ lastTransient = e;
+ sleepBeforeRetry(attempt);
+ }
+ }
+ Activator.getLogger().warn("Failed to fetch notifications after retries; using cache if present", lastTransient);
+ return cached;
+ }
+
+ private HttpResponse getResourceFromRemote(final String etag) throws IOException, InterruptedException {
+ final HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(endpointUrl))
+ .timeout(Duration.ofSeconds(TIMEOUT_SECONDS));
+ Optional.ofNullable(etag).ifPresent(tag -> requestBuilder.header("If-None-Match", tag));
+ return httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString());
+ }
+
+ private Optional readLocalFile(final String fileUrl) {
+ try {
+ // Prefer strict URI parsing; fall back to stripping the scheme for a plain path if the URI is not
+ // strictly legal (e.g. an un-encoded path pasted as file:///...).
+ Path path;
+ try {
+ path = Path.of(URI.create(fileUrl));
+ } catch (IllegalArgumentException e) {
+ path = Path.of(fileUrl.replaceFirst("(?i)^file://", ""));
+ }
+ return validate(Files.readString(path));
+ } catch (Exception e) {
+ Activator.getLogger().warn("Failed to read local notifications file: " + fileUrl, e);
+ return Optional.empty();
+ }
+ }
+
+ private Optional getResourceFromCache() {
+ try {
+ if (Files.exists(cachePath)) {
+ final Optional parsed = validate(Files.readString(cachePath));
+ if (parsed.isEmpty()) {
+ Files.deleteIfExists(cachePath);
+ Activator.getLogger().info("Deleted corrupt cached notifications file");
+ }
+ return parsed;
+ }
+ } catch (Exception e) {
+ Activator.getLogger().warn("Error reading cached notifications", e);
+ }
+ return Optional.empty();
+ }
+
+ private Optional validate(final String content) {
+ if (content == null || content.isBlank()) {
+ return Optional.empty();
+ }
+ // Guard against an unexpectedly large payload (a mis-pointed endpoint, or a hijacked/oversized file) so a
+ // poll cannot buffer an arbitrary amount into memory + attempt to parse it.
+ if (content.length() > MAX_PAYLOAD_CHARS) {
+ Activator.getLogger().warn("Notifications payload exceeds " + MAX_PAYLOAD_CHARS
+ + " chars (" + content.length() + "); ignoring");
+ return Optional.empty();
+ }
+ try {
+ return Optional.ofNullable(OBJECT_MAPPER.readValue(content, NotificationsList.class));
+ } catch (Exception e) {
+ Activator.getLogger().warn("Failed to parse notifications payload", e);
+ return Optional.empty();
+ }
+ }
+
+ private Optional validateAndCache(final HttpResponse response) {
+ final String body = response.body();
+ final Optional parsed = validate(body);
+ if (parsed.isEmpty()) {
+ // Do not cache a bad body; keep any prior valid cache untouched.
+ return getResourceFromCache();
+ }
+ Path tmp = null;
+ try {
+ tmp = cachePath.resolveSibling(cachePath.getFileName() + ".tmp");
+ Files.createDirectories(cachePath.getParent());
+ Files.writeString(tmp, body);
+ Files.move(tmp, cachePath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
+ // Persist the ETag ONLY after the cache write succeeds, so a later If-None-Match 304 can be honored by
+ // the on-disk cache. Storing the ETag without a matching cache would make 304s unserveable.
+ response.headers().firstValue("ETag")
+ .ifPresent(etag -> Activator.getPluginStore().put(endpointUrl, etag));
+ } catch (Exception e) {
+ Activator.getLogger().warn("Failed to cache notifications file", e);
+ // Clean up a leaked temp file if the atomic move did not consume it.
+ if (tmp != null) {
+ try {
+ Files.deleteIfExists(tmp);
+ } catch (Exception cleanupError) {
+ Activator.getLogger().warn("Failed to delete temp notifications file", cleanupError);
+ }
+ }
+ }
+ return parsed;
+ }
+
+ private void sleepBeforeRetry(final int attempt) {
+ if (attempt >= MAX_RETRIES - 1) {
+ return;
+ }
+ try {
+ Thread.sleep(RETRY_BASE_DELAY_MS * (1L << attempt));
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationsList.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationsList.java
new file mode 100644
index 000000000..fc128e8ce
--- /dev/null
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/NotificationsList.java
@@ -0,0 +1,17 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.notifications;
+
+import java.util.List;
+
+/**
+ * Root of the hosted notifications file: {@code { "schema": { "version": "2.0" }, "notifications": [ ... ] }}.
+ * The schema version is parsed but not validated (matching the JetBrains client), and {@code notifications}
+ * may be {@code null} or empty when there is nothing to show.
+ */
+public record NotificationsList(Schema schema, List notifications) {
+
+ /** Schema descriptor; only the version string is carried. */
+ public record Schema(String version) { }
+}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/ProcessNotifications.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/ProcessNotifications.java
new file mode 100644
index 000000000..571b3813a
--- /dev/null
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/ProcessNotifications.java
@@ -0,0 +1,150 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.notifications;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.ui.PlatformUI;
+
+import software.aws.toolkits.eclipse.amazonq.notifications.AmazonQNotificationPopup.NotificationAction;
+import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.LocalizedContent;
+import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationScheduleType;
+import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationSeverity;
+import software.aws.toolkits.eclipse.amazonq.plugin.Activator;
+
+/**
+ * Filters a fetched notifications payload and shows the survivors. Runs on the poll (worker) thread: it snapshots the
+ * system/auth state once, applies STARTUP-once + dismissal + rules + in-session dedup, then marshals each surviving
+ * toast onto the SWT UI thread. STARTUP notifications show only on the first poll of a session; an undismissed EMERGENCY
+ * shows once per session (guarded by an in-memory set) rather than re-toasting on every poll.
+ */
+public final class ProcessNotifications {
+
+ /**
+ * Renders a notification that has passed all filtering. Injectable so tests can observe without SWT.
+ * The {@code completion} consumer must be invoked with {@code true} once the toast has actually rendered
+ * (so we only then commit telemetry + consume the startup window) or {@code false} if rendering was skipped
+ * or failed (so the notification can be retried on a later poll).
+ */
+ public interface NotificationDisplay {
+ void show(String id, NotificationData notification, LocalizedContent content,
+ List actions, java.util.function.Consumer completion);
+ }
+
+ private final AtomicBoolean startupWindowOpen = new AtomicBoolean(true);
+ private final Set shownThisSession = ConcurrentHashMap.newKeySet();
+ private final NotificationDismissalStore dismissalStore;
+ private final NotificationDisplay display;
+
+ public ProcessNotifications(final NotificationDismissalStore dismissalStore) {
+ this(dismissalStore, ProcessNotifications::showToast);
+ }
+
+ public ProcessNotifications(final NotificationDismissalStore dismissalStore, final NotificationDisplay display) {
+ this.dismissalStore = dismissalStore;
+ this.display = display;
+ }
+
+ public void process(final NotificationsList list) {
+ if (list == null || list.notifications() == null || list.notifications().isEmpty()) {
+ return;
+ }
+ // Whether STARTUP notifications are still eligible this session. Consumed only once a STARTUP notification
+ // actually survives all filters and is displayed (see processOne) — NOT merely because the first poll ran —
+ // so a STARTUP item that is dismissed/rule-filtered/blank on the first poll can still show on a later poll
+ // in the same session once it qualifies.
+ final boolean startupEligible = startupWindowOpen.get();
+ final SystemDetails sys = SystemDetailsCollector.collect();
+
+ for (final NotificationData notification : list.notifications()) {
+ if (notification == null) {
+ continue;
+ }
+ try {
+ processOne(notification, startupEligible, sys);
+ } catch (Exception e) {
+ Activator.getLogger().warn("Skipping notification that failed to process: " + notification.id(), e);
+ }
+ }
+ }
+
+ private void processOne(final NotificationData notification, final boolean startupEligible,
+ final SystemDetails sys) {
+ final String id = notification.id();
+ if (id == null) {
+ return;
+ }
+ final boolean isStartup = notification.schedule() != null
+ && notification.schedule().type() == NotificationScheduleType.STARTUP;
+ if (isStartup && !startupEligible) {
+ return;
+ }
+ if (dismissalStore.isDismissed(id)) {
+ return;
+ }
+ if (!RulesEngine.displayNotification(notification, sys)) {
+ return;
+ }
+ if (notification.content() == null || notification.content().enUs() == null) {
+ Activator.getLogger().info("Skipping notification with no en-US content: " + id);
+ return;
+ }
+ final LocalizedContent content = notification.content().enUs();
+ if (isBlank(content.title()) || isBlank(content.description())) {
+ Activator.getLogger().info("Skipping notification with blank title/description: " + id);
+ return;
+ }
+ if (!shownThisSession.add(id)) {
+ return;
+ }
+ final List actions = new ArrayList<>(NotificationActionFactory.createActions(
+ id, notification.actions(), content.title(), content.description()));
+ // The explicit "Dismiss" button persists the dismissal so the notification does not reappear;
+ // closing/auto-fading or clicking another action does NOT dismiss (an emergency re-shows next session).
+ actions.add(new NotificationAction("Dismiss", () -> dismissalStore.dismiss(id)));
+ final boolean isStartupNotification = isStartup;
+ // Telemetry + startup-window consumption are committed only after the toast actually renders (completion
+ // == true). If rendering is skipped/failed, un-mark it so a later poll can retry.
+ display.show(id, notification, content, actions, rendered -> {
+ if (Boolean.TRUE.equals(rendered)) {
+ NotificationTelemetryProvider.emitShowNotification(id);
+ if (isStartupNotification) {
+ startupWindowOpen.set(false);
+ }
+ } else {
+ shownThisSession.remove(id);
+ }
+ });
+ }
+
+ private static void showToast(final String id, final NotificationData notification, final LocalizedContent content,
+ final List actions, final java.util.function.Consumer completion) {
+ final NotificationSeverity severity = NotificationSeverity.fromString(notification.severity());
+ Activator.getLogger().info("Showing notification toast: " + id + " (severity=" + severity + ")");
+ Display.getDefault().asyncExec(() -> {
+ if (!PlatformUI.isWorkbenchRunning()) {
+ Activator.getLogger().info("Workbench not running; skipping notification toast: " + id);
+ completion.accept(false);
+ return;
+ }
+ try {
+ new AmazonQNotificationPopup(Display.getCurrent(), content.title(), content.description(), severity,
+ actions).open();
+ completion.accept(true);
+ } catch (Exception e) {
+ Activator.getLogger().error("Failed to render notification toast: " + id, e);
+ completion.accept(false);
+ }
+ });
+ }
+
+ private static boolean isBlank(final String s) {
+ return s == null || s.isBlank();
+ }
+}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/RulesEngine.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/RulesEngine.java
new file mode 100644
index 000000000..779ad25bb
--- /dev/null
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/RulesEngine.java
@@ -0,0 +1,171 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.notifications;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.maven.artifact.versioning.ArtifactVersion;
+
+import software.aws.toolkits.eclipse.amazonq.lsp.manager.fetcher.ArtifactUtils;
+import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.AuthxType;
+import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.ComputeType;
+import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.ExtensionType;
+import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.NotificationDisplayCondition;
+import software.aws.toolkits.eclipse.amazonq.notifications.NotificationData.SystemType;
+
+/**
+ * Pure evaluator that decides whether a notification's display conditions match the current system/auth state.
+ * Ported 1:1 from the JetBrains RulesEngine: a null condition matches everyone; a present condition is an AND of its
+ * five optional blocks; version comparisons use semver only for {@code ide.version} and {@code extension.version}.
+ */
+public final class RulesEngine {
+
+ private static final String CLEAN_SEMVER = "^\\d+(\\.\\d+)*$";
+
+ private RulesEngine() {
+ // prevent instantiation
+ }
+
+ /** {@code condition == null} shows the notification to everyone. */
+ public static boolean displayNotification(final NotificationData notification, final SystemDetails sys) {
+ final NotificationDisplayCondition condition = notification.condition();
+ return condition == null || matchesAllRules(condition, sys);
+ }
+
+ static boolean matchesAllRules(final NotificationDisplayCondition c, final SystemDetails sys) {
+ final boolean compute = c.compute() == null
+ || matchesCompute(c.compute(), sys.computeType(), sys.computeArchitecture());
+ final boolean os = c.os() == null || matchesOs(c.os(), sys.osType(), sys.osVersion());
+ final boolean ide = c.ide() == null || matchesIde(c.ide(), sys.ideType(), sys.ideVersion());
+ final boolean extension = matchesExtension(c.extension(), sys.pluginVersions());
+ final boolean authx = matchesAuth(c.authx(), sys);
+ return compute && os && ide && extension && authx;
+ }
+
+ private static boolean matchesCompute(final ComputeType nc, final String type, final String arch) {
+ final boolean typeMatch = nc.type() == null || evaluateNotificationExpression(nc.type(), type);
+ final boolean archMatch = nc.architecture() == null || evaluateNotificationExpression(nc.architecture(), arch);
+ return typeMatch && archMatch;
+ }
+
+ private static boolean matchesOs(final SystemType no, final String os, final String osVersion) {
+ final boolean typeMatch = no.type() == null || evaluateNotificationExpression(no.type(), os);
+ final boolean versionMatch = no.version() == null || evaluateNotificationExpression(no.version(), osVersion);
+ return typeMatch && versionMatch;
+ }
+
+ private static boolean matchesIde(final SystemType ni, final String ide, final String ideVersion) {
+ final boolean typeMatch = ni.type() == null || evaluateNotificationExpression(ni.type(), ide);
+ final boolean versionMatch = ni.version() == null || evaluateNotificationExpression(ni.version(), ideVersion, true);
+ return typeMatch && versionMatch;
+ }
+
+ private static boolean matchesExtension(final List ne, final Map installedVersions) {
+ if (ne == null || ne.isEmpty()) {
+ return true;
+ }
+ boolean anyInstalled = false;
+ for (final ExtensionType ext : ne) {
+ final String installed = installedVersions.get(ext.id());
+ if (installed == null) {
+ continue;
+ }
+ anyInstalled = true;
+ // Development builds must never receive notifications.
+ if (installed.toLowerCase(java.util.Locale.ROOT).contains("snapshot")) {
+ return false;
+ }
+ if (ext.version() != null && !evaluateNotificationExpression(ext.version(), installed, true)) {
+ return false;
+ }
+ }
+ // Declared but none of the extensions are installed -> do not show.
+ return anyInstalled;
+ }
+
+ private static boolean matchesAuth(final List na, final SystemDetails sys) {
+ if (na == null || na.isEmpty()) {
+ return true;
+ }
+ for (final AuthxType feature : na) {
+ if (!"q".equals(feature.feature())) {
+ // Faithful to JetBrains: any non-"q" feature passes.
+ continue;
+ }
+ final FeatureAuthDetails auth = sys.qAuth();
+ if (auth == null) {
+ return false;
+ }
+ final boolean typeMatch = feature.type() == null
+ || evaluateNotificationExpression(feature.type(), auth.connectionType());
+ final boolean regionMatch = feature.region() == null
+ || evaluateNotificationExpression(feature.region(), auth.region());
+ final boolean stateMatch = feature.connectionState() == null
+ || evaluateNotificationExpression(feature.connectionState(), auth.connectionState());
+ // NOTE: ssoScopes is intentionally not evaluated here — Eclipse's SystemDetailsCollector does not yet
+ // collect the connection's SSO scopes, so there is no actual value to compare against. Payloads should
+ // not rely on ssoScopes for Eclipse targeting until it is collected (tracked as a follow-up); an
+ // ssoScopes clause is currently a no-op rather than a match/mismatch.
+ if (!(typeMatch && regionMatch && stateMatch)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /** Evaluates an expression against an actual value, using string comparison for ordering operators. */
+ public static boolean evaluateNotificationExpression(final NotificationExpression expr, final String value) {
+ return evaluateNotificationExpression(expr, value, false);
+ }
+
+ /** Evaluates an expression; when {@code useSemver} is true, ordering operators compare versions semantically. */
+ public static boolean evaluateNotificationExpression(final NotificationExpression expr, final String value,
+ final boolean useSemver) {
+ if (expr instanceof NotificationExpression.ComparisonCondition c) {
+ // Anchor on the payload value so a null system value is a non-match, not an NPE.
+ return c.value() != null && c.value().equals(value);
+ } else if (expr instanceof NotificationExpression.NotEqualsCondition c) {
+ return c.value() == null || !c.value().equals(value);
+ } else if (expr instanceof NotificationExpression.GreaterThanCondition c) {
+ // A null actual system value cannot satisfy an ordering constraint.
+ return value != null && compare(value, c.value(), useSemver) > 0;
+ } else if (expr instanceof NotificationExpression.GreaterThanOrEqualsCondition c) {
+ return value != null && compare(value, c.value(), useSemver) >= 0;
+ } else if (expr instanceof NotificationExpression.LessThanCondition c) {
+ return value != null && compare(value, c.value(), useSemver) < 0;
+ } else if (expr instanceof NotificationExpression.LessThanOrEqualsCondition c) {
+ return value != null && compare(value, c.value(), useSemver) <= 0;
+ } else if (expr instanceof NotificationExpression.AnyOfCondition c) {
+ return c.value().contains(value);
+ } else if (expr instanceof NotificationExpression.NoneOfCondition c) {
+ return !c.value().contains(value);
+ } else if (expr instanceof NotificationExpression.NotCondition c) {
+ return !evaluateNotificationExpression(c.expectedValue(), value, useSemver);
+ } else if (expr instanceof NotificationExpression.OrCondition c) {
+ return c.expectedValueList().stream().anyMatch(e -> evaluateNotificationExpression(e, value, useSemver));
+ } else if (expr instanceof NotificationExpression.AndCondition c) {
+ return c.expectedValueList().stream().allMatch(e -> evaluateNotificationExpression(e, value, useSemver));
+ }
+ return true;
+ }
+
+ private static int compare(final String actual, final String expected, final boolean useSemver) {
+ return useSemver ? compareSemver(actual, expected) : actual.compareTo(expected);
+ }
+
+ private static int compareSemver(final String actual, final String expected) {
+ // Match JetBrains: fall back to lexical comparison when either side is not clean numeric semver.
+ if (!isCleanSemver(actual) || !isCleanSemver(expected)) {
+ return actual.compareTo(expected);
+ }
+ final ArtifactVersion actualVersion = ArtifactUtils.parseVersion(actual);
+ final ArtifactVersion expectedVersion = ArtifactUtils.parseVersion(expected);
+ return actualVersion.compareTo(expectedVersion);
+ }
+
+ private static boolean isCleanSemver(final String v) {
+ return v != null && v.matches(CLEAN_SEMVER);
+ }
+}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/SystemDetails.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/SystemDetails.java
new file mode 100644
index 000000000..c69d022d6
--- /dev/null
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/SystemDetails.java
@@ -0,0 +1,18 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.notifications;
+
+import java.util.Map;
+
+/** Immutable snapshot of the current system + auth state that a notification's conditions are evaluated against. */
+public record SystemDetails(
+ String computeType,
+ String computeArchitecture,
+ String osType,
+ String osVersion,
+ String ideType,
+ String ideVersion,
+ Map pluginVersions,
+ FeatureAuthDetails qAuth) {
+}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/SystemDetailsCollector.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/SystemDetailsCollector.java
new file mode 100644
index 000000000..e47194b70
--- /dev/null
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/notifications/SystemDetailsCollector.java
@@ -0,0 +1,136 @@
+// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.eclipse.amazonq.notifications;
+
+import java.util.Map;
+
+import org.eclipse.core.runtime.Platform;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.FrameworkUtil;
+import org.osgi.framework.Version;
+
+import software.aws.toolkits.eclipse.amazonq.lsp.auth.model.AuthState;
+import software.aws.toolkits.eclipse.amazonq.lsp.auth.model.AuthStateType;
+import software.aws.toolkits.eclipse.amazonq.lsp.auth.model.LoginType;
+import software.aws.toolkits.eclipse.amazonq.plugin.Activator;
+
+/** Resolves the current system + auth state into an immutable {@link SystemDetails} snapshot for the rules engine. */
+public final class SystemDetailsCollector {
+
+ private static final String PLATFORM_BUNDLE_ID = "org.eclipse.platform";
+ private static final String UNKNOWN = "Unknown";
+
+ private SystemDetailsCollector() {
+ // prevent instantiation
+ }
+
+ /** Snapshots {@code getAuthState()} once and resolves everything into an immutable {@link SystemDetails}. */
+ public static SystemDetails collect() {
+ final Bundle pluginBundle = FrameworkUtil.getBundle(SystemDetailsCollector.class);
+ final String pluginId = pluginBundle != null ? pluginBundle.getSymbolicName() : UNKNOWN;
+ // Use a clean major.minor.micro string (drop the OSGi qualifier, e.g. "2.7.4.202607161757") so the rules
+ // engine compares extension.version with semver, not lexical ordering. See resolveIdeVersion for the same.
+ final String pluginVersion = pluginBundle != null ? cleanVersion(pluginBundle.getVersion()) : UNKNOWN;
+
+ return new SystemDetails(
+ "Local",
+ Platform.getOSArch(),
+ System.getProperty("os.name"),
+ System.getProperty("os.version"),
+ "Eclipse",
+ resolveIdeVersion(),
+ Map.of(pluginId, pluginVersion),
+ resolveQAuth());
+ }
+
+ /** Amazon Q Eclipse bundle symbolic name — the key notification payloads use for {@code extension.id}. */
+ public static String pluginId() {
+ final Bundle pluginBundle = FrameworkUtil.getBundle(SystemDetailsCollector.class);
+ return pluginBundle != null ? pluginBundle.getSymbolicName() : UNKNOWN;
+ }
+
+ /**
+ * Whether this is an unreleased/development build. Tycho replaces the {@code .qualifier} segment with a numeric
+ * build timestamp at release time, so a bundle whose qualifier is still the literal {@code "qualifier"} (PDE/dev
+ * launch) or contains {@code "snapshot"} is a development build that should not receive production notifications.
+ */
+ public static boolean isDevBuild() {
+ final Bundle pluginBundle = FrameworkUtil.getBundle(SystemDetailsCollector.class);
+ if (pluginBundle == null) {
+ return true;
+ }
+ final String qualifier = pluginBundle.getVersion().getQualifier();
+ if (qualifier == null || qualifier.isBlank()) {
+ return false;
+ }
+ final String q = qualifier.toLowerCase(java.util.Locale.ROOT);
+ return q.equals("qualifier") || q.contains("snapshot");
+ }
+
+ private static String resolveIdeVersion() {
+ final Bundle platform = Platform.getBundle(PLATFORM_BUNDLE_ID);
+ if (platform == null) {
+ return UNKNOWN;
+ }
+ return cleanVersion(platform.getVersion());
+ }
+
+ /** Renders an OSGi {@link Version} as clean {@code major.minor.micro}, dropping the qualifier segment. */
+ private static String cleanVersion(final Version v) {
+ if (v == null) {
+ return UNKNOWN;
+ }
+ return v.getMajor() + "." + v.getMinor() + "." + v.getMicro();
+ }
+
+ private static FeatureAuthDetails resolveQAuth() {
+ final AuthState authState = Activator.getLoginService().getAuthState();
+ if (authState == null) {
+ return new FeatureAuthDetails(UNKNOWN, UNKNOWN, "NotConnected");
+ }
+ return new FeatureAuthDetails(
+ mapConnectionType(authState.loginType()),
+ mapRegion(authState),
+ mapConnectionState(authState.authStateType()));
+ }
+
+ private static String mapConnectionType(final LoginType loginType) {
+ if (loginType == null) {
+ return UNKNOWN;
+ }
+ switch (loginType) {
+ case BUILDER_ID:
+ return "BuilderId";
+ case IAM_IDENTITY_CENTER:
+ return "Idc";
+ default:
+ return UNKNOWN;
+ }
+ }
+
+ private static String mapConnectionState(final AuthStateType authStateType) {
+ if (authStateType == null) {
+ return "NotConnected";
+ }
+ switch (authStateType) {
+ case LOGGED_IN:
+ return "Connected";
+ case EXPIRED:
+ return "Expired";
+ case LOGGED_OUT:
+ default:
+ return "NotConnected";
+ }
+ }
+
+ private static String mapRegion(final AuthState authState) {
+ if (authState.loginParams() != null && authState.loginParams().getLoginIdcParams() != null) {
+ final String region = authState.loginParams().getLoginIdcParams().getRegion();
+ if (region != null && !region.isBlank()) {
+ return region;
+ }
+ }
+ return UNKNOWN;
+ }
+}
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 9fc1dbb60..fcd3221a6 100644
--- a/plugin/src/software/aws/toolkits/eclipse/amazonq/plugin/Activator.java
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/plugin/Activator.java
@@ -12,6 +12,7 @@
import software.aws.toolkits.eclipse.amazonq.inlineChat.InlineChatEditorListener;
import software.aws.toolkits.eclipse.amazonq.lsp.auth.DefaultLoginService;
import software.aws.toolkits.eclipse.amazonq.lsp.auth.LoginService;
+import software.aws.toolkits.eclipse.amazonq.lsp.editor.ActiveEditorChangeListener;
import software.aws.toolkits.eclipse.amazonq.providers.browser.AmazonQBrowserProvider;
import software.aws.toolkits.eclipse.amazonq.providers.lsp.LspProvider;
import software.aws.toolkits.eclipse.amazonq.providers.lsp.LspProviderImpl;
@@ -21,6 +22,7 @@
import software.aws.toolkits.eclipse.amazonq.util.DefaultCodeReferenceLoggingService;
import software.aws.toolkits.eclipse.amazonq.util.LoggingService;
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.router.ViewRouter;
import software.aws.toolkits.eclipse.workspace.WorkspaceChangeListener;
@@ -39,6 +41,7 @@ public class Activator extends AbstractUIPlugin {
private static ViewRouter viewRouter = ViewRouter.builder().build();
private final InlineChatEditorListener editorListener;
private static WorkspaceChangeListener workspaceListener = WorkspaceChangeListener.getInstance();
+ private static ActiveEditorChangeListener activeEditorListener = ActiveEditorChangeListener.getInstance();
public Activator() {
super();
@@ -56,14 +59,17 @@ public Activator() {
editorListener = InlineChatEditorListener.getInstance();
editorListener.initialize();
workspaceListener.start();
+ activeEditorListener.initialize();
}
@Override
public final void stop(final BundleContext context) throws Exception {
+ NotificationPollingService.getInstance().shutdown();
AmazonQBrowserProvider.getInstance().dispose();
super.stop(context);
plugin = null;
workspaceListener.stop();
+ activeEditorListener.stop();
ThreadingUtils.shutdown();
}
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/preferences/AmazonQPreferenceInitializer.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/preferences/AmazonQPreferenceInitializer.java
index a1cb028b0..8f5c2fe8f 100644
--- a/plugin/src/software/aws/toolkits/eclipse/amazonq/preferences/AmazonQPreferenceInitializer.java
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/preferences/AmazonQPreferenceInitializer.java
@@ -9,6 +9,7 @@
import org.eclipse.jface.preference.IPreferenceStore;
import org.eclipse.lsp4j.DidChangeConfigurationParams;
+import software.aws.toolkits.eclipse.amazonq.notifications.NotificationPollingService;
import software.aws.toolkits.eclipse.amazonq.plugin.Activator;
import software.aws.toolkits.eclipse.amazonq.util.ThreadingUtils;
@@ -18,14 +19,17 @@ public class AmazonQPreferenceInitializer extends AbstractPreferenceInitializer
public final void initializeDefaultPreferences() {
IPreferenceStore store = Activator.getDefault().getPreferenceStore();
store.setDefault(AmazonQPreferencePage.CODE_REFERENCE_OPT_IN, true);
- store.setDefault(AmazonQPreferencePage.WORKSPACE_INDEX, false);
- store.setDefault(AmazonQPreferencePage.USE_GPU_FOR_INDEXING, false);
- store.setDefault(AmazonQPreferencePage.INDEX_WORKER_THREADS, 0);
store.setDefault(AmazonQPreferencePage.TELEMETRY_OPT_IN, true);
store.setDefault(AmazonQPreferencePage.Q_DATA_SHARING, true);
store.setDefault(AmazonQPreferencePage.HTTPS_PROXY, "");
store.setDefault(AmazonQPreferencePage.CA_CERT, "");
+ store.setDefault(AmazonQPreferencePage.NOTIFICATIONS_OPT_IN, true);
+ store.setDefault(AmazonQPreferencePage.NOTIFICATIONS_ENDPOINT_OVERRIDE, "");
store.addPropertyChangeListener(event -> {
+ // React to the notifications kill-switch so it can pause/resume polling within a session (no restart).
+ if (AmazonQPreferencePage.NOTIFICATIONS_OPT_IN.equals(event.getProperty())) {
+ NotificationPollingService.getInstance().onEnabledPreferenceChanged();
+ }
ThreadingUtils.executeAsyncTask(() -> {
Activator.getLspProvider().getAmazonQServer()
.thenAccept(server -> server.getWorkspaceService().didChangeConfiguration(
diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/preferences/AmazonQPreferencePage.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/preferences/AmazonQPreferencePage.java
index 9d4c83d1c..7dee941f9 100644
--- a/plugin/src/software/aws/toolkits/eclipse/amazonq/preferences/AmazonQPreferencePage.java
+++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/preferences/AmazonQPreferencePage.java
@@ -36,21 +36,12 @@
public class AmazonQPreferencePage extends FieldEditorPreferencePage implements IWorkbenchPreferencePage {
public static final String PREFERENCE_STORE_ID = "software.aws.toolkits.eclipse.preferences";
public static final String CODE_REFERENCE_OPT_IN = "codeReferenceOptIn";
- public static final String WORKSPACE_INDEX = "workspaceIndex";
- public static final String USE_GPU_FOR_INDEXING = "useGpuForIndexing";
- public static final String INDEX_WORKER_THREADS = "indexWorkerThreads";
public static final String TELEMETRY_OPT_IN = "telemetryOptIn";
public static final String Q_DATA_SHARING = "qDataSharing";
public static final String HTTPS_PROXY = "httpsProxy";
public static final String CA_CERT = "customCaCert";
-
- private Boolean isWorkspaceIndexChecked;
- private Boolean isGpuIndexingChecked;
- private int indexWorkerThreads;
-
- private Boolean changedWorkspaceIndexChecked;
- private Boolean changedGpuIndexingChecked;
- private int changedIndexWorkerThreads;
+ public static final String NOTIFICATIONS_OPT_IN = "notificationsOptIn";
+ public static final String NOTIFICATIONS_ENDPOINT_OVERRIDE = "notificationsEndpointOverride";
private Boolean isTelemetryOptInChecked;
private Boolean isQDataSharingOptInChecked;
@@ -67,12 +58,6 @@ public AmazonQPreferencePage() {
@Override
public final void init(final IWorkbench workbench) {
- isWorkspaceIndexChecked = preferenceStore.getBoolean(WORKSPACE_INDEX);
- changedWorkspaceIndexChecked = preferenceStore.getBoolean(WORKSPACE_INDEX);
- isGpuIndexingChecked = preferenceStore.getBoolean(USE_GPU_FOR_INDEXING);
- changedGpuIndexingChecked = preferenceStore.getBoolean(USE_GPU_FOR_INDEXING);
- indexWorkerThreads = preferenceStore.getInt(INDEX_WORKER_THREADS);
- changedIndexWorkerThreads = preferenceStore.getInt(INDEX_WORKER_THREADS);
isTelemetryOptInChecked = preferenceStore.getBoolean(TELEMETRY_OPT_IN);
changedTelemetryOptInChecked = preferenceStore.getBoolean(TELEMETRY_OPT_IN);
isQDataSharingOptInChecked = preferenceStore.getBoolean(Q_DATA_SHARING);
@@ -87,14 +72,12 @@ protected final void createFieldEditors() {
createHorizontalSeparator();
createHeading("Code Suggestions");
createCodeReferenceOptInField();
- createHeading("Workspace Indexing");
- createWorkspaceIndexField();
- createUseGpuForIndexingField();
- createIndexWorkerThreadsField();
createHeading("Data Sharing");
createTelemetryOptInField();
createHorizontalSeparator();
createQDataSharingField();
+ createHeading("Notifications");
+ createNotificationsOptInField();
createHeading("Proxy Settings");
createHttpsProxyField();
createCaCertField();
@@ -148,65 +131,6 @@ public void widgetSelected(final SelectionEvent event) {
});
}
- private void createWorkspaceIndexField() {
- Composite workspaceIndexComposite = new Composite(getFieldEditorParent(), SWT.NONE);
- workspaceIndexComposite.setLayout(new GridLayout(2, false));
- GridData workspaceIndexCompositeData = new GridData(SWT.FILL, SWT.CENTER, true, false);
- workspaceIndexCompositeData.horizontalIndent = 20;
- workspaceIndexComposite.setLayoutData(workspaceIndexCompositeData);
-
- BooleanFieldEditor workspaceIndex = new BooleanFieldEditor(WORKSPACE_INDEX, "Workspace Index", workspaceIndexComposite) {
- @Override
- protected void valueChanged(final boolean oldValue, final boolean newValue) {
- isWorkspaceIndexChecked = newValue;
- }
- };
- addField(workspaceIndex);
-
- createLabel("""
- When you add @workspace to your question in Amazon Q chat, Amazon Q will index your workspace files locally\
- \nto use as context for its response. Extra CPU usage is expected while indexing a workspace. This will not\
- \nimpact Amazon Q features or your IDE, but you may manage CPU usage by setting the number of index threads.
- """, 20, workspaceIndexComposite);
- }
-
- private void createUseGpuForIndexingField() {
- Composite useGpuComposite = new Composite(getFieldEditorParent(), SWT.NONE);
- useGpuComposite.setLayout(new GridLayout(2, false));
- GridData useGpuCompositeData = new GridData(SWT.FILL, SWT.CENTER, true, false);
- useGpuCompositeData.horizontalIndent = 20;
- useGpuComposite.setLayoutData(useGpuCompositeData);
-
- BooleanFieldEditor useGpuForIndexing = new BooleanFieldEditor(USE_GPU_FOR_INDEXING, "Use GPU for Indexing", useGpuComposite) {
- @Override
- protected void valueChanged(final boolean oldValue, final boolean newValue) {
- isGpuIndexingChecked = newValue;
- }
- };
- addField(useGpuForIndexing);
-
- createLabel("""
- Enable GPU to help index your local workspace files. Only applies to Linux and Windows.
- """, 20, useGpuComposite);
- }
-
- private void createIndexWorkerThreadsField() {
- Composite indexWorkerThreadsComposite = new Composite(getFieldEditorParent(), SWT.NONE);
- indexWorkerThreadsComposite.setLayout(new GridLayout(2, false));
- GridData indexWorkerThreadsCompositeData = new GridData(SWT.LEFT, SWT.CENTER, true, false);
- indexWorkerThreadsCompositeData.horizontalIndent = 20;
- indexWorkerThreadsComposite.setLayoutData(indexWorkerThreadsCompositeData);
-
- StringFieldEditor indexWorkerThreads = new StringFieldEditor(INDEX_WORKER_THREADS, "Index Worker Threads", 10, indexWorkerThreadsComposite);
- addField(indexWorkerThreads);
-
- createLabel("""
- Number of worker threads of Amazon Q local index process. '0' will use the system default worker threads for balance\
- \nperformance. You may increase this number to more quickly index your workspace, but only up to your hardware's number\
- \nof CPU cores. Please restart Eclipse after changing worker threads.
- """, 20, getFieldEditorParent());
- }
-
private void createTelemetryOptInField() {
Composite telemetryOptInComposite = new Composite(getFieldEditorParent(), SWT.NONE);
telemetryOptInComposite.setLayout(new GridLayout(2, false));
@@ -234,6 +158,18 @@ public void widgetSelected(final SelectionEvent event) {
});
}
+ private void createNotificationsOptInField() {
+ Composite notificationsOptInComposite = new Composite(getFieldEditorParent(), SWT.NONE);
+ notificationsOptInComposite.setLayout(new GridLayout(2, false));
+ GridData notificationsOptInCompositeData = new GridData(SWT.FILL, SWT.CENTER, true, false);
+ notificationsOptInCompositeData.horizontalIndent = 20;
+ notificationsOptInComposite.setLayoutData(notificationsOptInCompositeData);
+
+ BooleanFieldEditor notificationsOptIn = new BooleanFieldEditor(NOTIFICATIONS_OPT_IN,
+ "Show Amazon Q notifications about known issues and available fixes", notificationsOptInComposite);
+ addField(notificationsOptIn);
+ }
+
private void createQDataSharingField() {
Composite qDataSharingComposite = new Composite(getFieldEditorParent(), SWT.NONE);
qDataSharingComposite.setLayout(new GridLayout(2, false));
@@ -351,23 +287,6 @@ private void sendUpdatedPreferences() {
isQDataSharingOptInChecked = changedDataSharingOptInChecked;
}
- if (changedWorkspaceIndexChecked != isWorkspaceIndexChecked) {
- AwsTelemetryProvider.emitModifySettingEvent("amazonQ.workspaceIndexing",
- changedWorkspaceIndexChecked.toString());
- isWorkspaceIndexChecked = changedWorkspaceIndexChecked;
- }
-
- if (changedGpuIndexingChecked != isGpuIndexingChecked) {
- AwsTelemetryProvider.emitModifySettingEvent("amazonQ.gpuIndexing",
- changedGpuIndexingChecked.toString());
- isGpuIndexingChecked = changedGpuIndexingChecked;
- }
-
- if (changedIndexWorkerThreads != indexWorkerThreads) {
- AwsTelemetryProvider.emitModifySettingEvent("amazonQ.indexThreads",
- String.valueOf(changedIndexWorkerThreads));
- indexWorkerThreads = changedIndexWorkerThreads;
- }
ThreadingUtils.executeAsyncTask(() -> CustomizationUtil.triggerChangeConfigurationNotification());
}
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 39fce82f2..3b7f2905e 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
@@ -100,6 +100,8 @@ private Optional resolveContent() {
String chatJsPath = chatAsset.get();
String themeVariables = chatTheme.getThemeVariables();
+ String webkitWorkarounds = getWebkitProgressWorkaround();
+ String eclipseWebkitScript = getEclipseWebkitScript();
return Optional.of(String.format("""
@@ -131,42 +133,75 @@ private Optional resolveContent() {
[class*="mynah-ui-icon-"] {
transform: translateZ(0);
}
+ %s
%s
+ %s