Skip to content

Commit 41f64ae

Browse files
authored
feat(notifications): notifications controller and view panel (#5828)
- NotificationsController - Fetches notifications and determines which ones to display. - NotificationsNode - Side panel that displays a given list (from controller) of notifications. The controller will fetch notifications from an endpoint (or local files during development). These notifications will be stored in memory and global state. They will then be evaluated for dismissal/criteria and if they pass they will be sent to the NotificationsNode for display. Extensions will call `NotificationController.pollForStartUp()` and `NotificationController.pollForEmergencies()` to get notifications. <img width="593" alt="image" src="https://github.com/user-attachments/assets/cb1304bb-4945-4fce-a89f-19bc2c9d4cad"> --- <!--- REMINDER: Ensure that your PR meets the guidelines in CONTRIBUTING.md --> License: I confirm that my contribution is made under the terms of the Apache 2.0 license.
1 parent 50d62ba commit 41f64ae

File tree

18 files changed

+1156
-7
lines changed

18 files changed

+1156
-7
lines changed

packages/amazonq/package.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,11 @@
188188
},
189189
"views": {
190190
"amazonq": [
191+
{
192+
"id": "aws.amazonq.notifications",
193+
"name": "%AWS.notifications.title%",
194+
"when": "!isCloud9 && !aws.isSageMaker && aws.amazonq.notifications.show"
195+
},
191196
{
192197
"type": "webview",
193198
"id": "aws.amazonq.AmazonCommonAuth",
@@ -370,6 +375,13 @@
370375
"group": "cw_chat"
371376
}
372377
],
378+
"view/item/context": [
379+
{
380+
"command": "_aws.amazonq.notifications.dismiss",
381+
"when": "viewItem == amazonqNotificationStartUp",
382+
"group": "inline@1"
383+
}
384+
],
373385
"aws.amazonq.submenu.feedback": [
374386
{
375387
"command": "aws.amazonq.submitFeedback",
@@ -397,6 +409,13 @@
397409
]
398410
},
399411
"commands": [
412+
{
413+
"command": "_aws.amazonq.notifications.dismiss",
414+
"title": "%AWS.generic.dismiss%",
415+
"category": "%AWS.amazonq.title%",
416+
"enablement": "isCloud9 || !aws.isWebExtHost",
417+
"icon": "$(remove-close)"
418+
},
400419
{
401420
"command": "aws.amazonq.explainCode",
402421
"title": "%AWS.command.amazonq.explainCode%",

packages/core/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
"./utils": "./dist/src/shared/utilities/index.js",
2828
"./feedback": "./dist/src/feedback/index.js",
2929
"./telemetry": "./dist/src/shared/telemetry/index.js",
30-
"./dev": "./dist/src/dev/index.js"
30+
"./dev": "./dist/src/dev/index.js",
31+
"./notifications": "./dist/src/notifications/index.js"
3132
},
3233
"contributes": {
3334
"icons": {

packages/core/package.nls.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"AWS.productName.cn": "Amazon Toolkit",
66
"AWS.amazonq.productName": "Amazon Q",
77
"AWS.codecatalyst.submenu.title": "Manage CodeCatalyst",
8+
"AWS.notifications.title": "Notifications",
89
"AWS.configuration.profileDescription": "The name of the credential profile to obtain credentials from.",
910
"AWS.configuration.description.lambda.recentlyUploaded": "Recently selected Lambda upload targets.",
1011
"AWS.configuration.description.ecs.openTerminalCommand": "The command to run when starting a new interactive terminal session.",
@@ -245,6 +246,7 @@
245246
"AWS.generic.promptUpdate": "Update...",
246247
"AWS.generic.preview": "Preview",
247248
"AWS.generic.viewDocs": "View Documentation",
249+
"AWS.generic.dismiss": "Dismiss",
248250
"AWS.ssmDocument.ssm.maxItemsComputed.desc": "Controls the maximum number of problems produced by the SSM Document language server.",
249251
"AWS.walkthrough.gettingStarted.title": "Get started with AWS",
250252
"AWS.walkthrough.gettingStarted.description": "These walkthroughs help you set up the AWS Toolkit.",

packages/core/src/codewhisperer/util/zipUtil.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { tempDirPath } from '../../shared/filesystemUtilities'
99
import { getLogger } from '../../shared/logger'
1010
import * as CodeWhispererConstants from '../models/constants'
1111
import { ToolkitError } from '../../shared/errors'
12-
import { fs } from '../../shared'
12+
import { fs } from '../../shared/fs/fs'
1313
import { getLoggerForScope } from '../service/securityScanHandler'
1414
import { runtimeLanguageContext } from './runtimeLanguageContext'
1515
import { CodewhispererLanguage } from '../../shared/telemetry/telemetry.gen'
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import * as vscode from 'vscode'
7+
import { ToolkitError } from '../shared/errors'
8+
import globals from '../shared/extensionGlobals'
9+
import { globalKey } from '../shared/globalState'
10+
import { NotificationsState, NotificationsStateConstructor, NotificationType, ToolkitNotification } from './types'
11+
import { HttpResourceFetcher } from '../shared/resourcefetcher/httpResourceFetcher'
12+
import { getLogger } from '../shared/logger/logger'
13+
import { NotificationsNode } from './panelNode'
14+
import { Commands } from '../shared/vscode/commands2'
15+
import { RuleEngine } from './rules'
16+
import { TreeNode } from '../shared/treeview/resourceTreeDataProvider'
17+
import { withRetries } from '../shared/utilities/functionUtils'
18+
import { FileResourceFetcher } from '../shared/resourcefetcher/fileResourceFetcher'
19+
import { isAmazonQ } from '../shared/extensionUtilities'
20+
21+
/**
22+
* Handles fetching and maintaining the state of in-IDE notifications.
23+
* Notifications are constantly polled from a known endpoint and then stored in global state.
24+
* The global state is used to compare if there are a change in notifications on the endpoint
25+
* or if the endpoint is not reachable.
26+
*
27+
* This class will send any notifications to {@link NotificationsNode} for display.
28+
* Notifications can be dismissed.
29+
*
30+
* Startup notifications - fetched each start up.
31+
* Emergency notifications - fetched at a regular interval.
32+
*/
33+
export class NotificationsController {
34+
public static readonly suggestedPollIntervalMs = 1000 * 60 * 10 // 10 minutes
35+
36+
public readonly storageKey: globalKey
37+
38+
/** Internal memory state that is written to global state upon modification. */
39+
private readonly state: NotificationsState
40+
41+
static #instance: NotificationsController | undefined
42+
43+
constructor(
44+
private readonly notificationsNode: NotificationsNode,
45+
private readonly fetcher: NotificationFetcher = new RemoteFetcher()
46+
) {
47+
if (!NotificationsController.#instance) {
48+
// Register on first creation only.
49+
registerDismissCommand()
50+
}
51+
NotificationsController.#instance = this
52+
53+
this.storageKey = 'aws.notifications'
54+
this.state = globals.globalState.tryGet<NotificationsState>(this.storageKey, NotificationsStateConstructor, {
55+
startUp: {},
56+
emergency: {},
57+
dismissed: [],
58+
})
59+
}
60+
61+
public pollForStartUp(ruleEngine: RuleEngine) {
62+
return this.poll(ruleEngine, 'startUp')
63+
}
64+
65+
public pollForEmergencies(ruleEngine: RuleEngine) {
66+
return this.poll(ruleEngine, 'emergency')
67+
}
68+
69+
private async poll(ruleEngine: RuleEngine, category: NotificationType) {
70+
try {
71+
await this.fetchNotifications(category)
72+
} catch (err: any) {
73+
getLogger('notifications').error(`Unable to fetch %s notifications: %s`, category, err)
74+
}
75+
76+
await this.displayNotifications(ruleEngine)
77+
}
78+
79+
private async displayNotifications(ruleEngine: RuleEngine) {
80+
const dismissed = new Set(this.state.dismissed)
81+
const startUp =
82+
this.state.startUp.payload?.notifications.filter(
83+
(n) => !dismissed.has(n.id) && ruleEngine.shouldDisplayNotification(n)
84+
) ?? []
85+
const emergency = (this.state.emergency.payload?.notifications ?? []).filter((n) =>
86+
ruleEngine.shouldDisplayNotification(n)
87+
)
88+
89+
NotificationsNode.instance.setNotifications(startUp, emergency)
90+
91+
// Emergency notifications can't be dismissed, but if the user minimizes the panel then
92+
// we don't want to focus it each time we set the notification nodes.
93+
// So we store it in dismissed once a focus has been fired for it.
94+
const newEmergencies = emergency.map((n) => n.id).filter((id) => !dismissed.has(id))
95+
if (newEmergencies.length > 0) {
96+
this.state.dismissed = [...this.state.dismissed, ...newEmergencies]
97+
await this.writeState()
98+
void this.notificationsNode.focusPanel()
99+
}
100+
}
101+
102+
/**
103+
* Permanently hides a notification from view. Only 'startUp' notifications can be dismissed.
104+
* Users are able to collapse or hide the notifications panel in native VSC if they want to
105+
* hide all notifications.
106+
*/
107+
public async dismissNotification(notificationId: string) {
108+
getLogger('notifications').debug('Dismissing notification: %s', notificationId)
109+
this.state.dismissed.push(notificationId)
110+
await this.writeState()
111+
112+
NotificationsNode.instance.dismissStartUpNotification(notificationId)
113+
}
114+
115+
/**
116+
* Fetch notifications from the endpoint and store them in the global state.
117+
*/
118+
private async fetchNotifications(category: NotificationType) {
119+
const response = await this.fetcher.fetch(category, this.state[category].eTag)
120+
if (!response.content) {
121+
getLogger('notifications').verbose('No new notifications for category: %s', category)
122+
return
123+
}
124+
125+
getLogger('notifications').verbose('ETAG has changed for notifications category: %s', category)
126+
127+
this.state[category].payload = JSON.parse(response.content)
128+
this.state[category].eTag = response.eTag
129+
await this.writeState()
130+
131+
getLogger('notifications').verbose(
132+
"Fetched notifications JSON for category '%s' with schema version: %s. There were %d notifications.",
133+
category,
134+
this.state[category].payload?.schemaVersion,
135+
this.state[category].payload?.notifications?.length
136+
)
137+
}
138+
139+
/**
140+
* Write the latest memory state to global state.
141+
*/
142+
private async writeState() {
143+
getLogger('notifications').debug('NotificationsController: Updating notifications state at %s', this.storageKey)
144+
145+
// Clean out anything in 'dismissed' that doesn't exist anymore.
146+
const notifications = new Set(
147+
[
148+
...(this.state.startUp.payload?.notifications ?? []),
149+
...(this.state.emergency.payload?.notifications ?? []),
150+
].map((n) => n.id)
151+
)
152+
this.state.dismissed = this.state.dismissed.filter((id) => notifications.has(id))
153+
154+
await globals.globalState.update(this.storageKey, this.state)
155+
}
156+
157+
static get instance() {
158+
if (this.#instance === undefined) {
159+
throw new ToolkitError('NotificationsController was accessed before it has been initialized.')
160+
}
161+
162+
return this.#instance
163+
}
164+
}
165+
166+
function registerDismissCommand() {
167+
const name = isAmazonQ() ? '_aws.amazonq.notifications.dismiss' : '_aws.toolkit.notifications.dismiss'
168+
169+
globals.context.subscriptions.push(
170+
Commands.register(name, async (node: TreeNode) => {
171+
const item = node?.getTreeItem()
172+
if (item instanceof vscode.TreeItem && item.command?.arguments) {
173+
// The command used to build the TreeNode contains the notification as an argument.
174+
/** See {@link NotificationsNode} for more info. */
175+
const notification = item.command?.arguments[0] as ToolkitNotification
176+
177+
await NotificationsController.instance.dismissNotification(notification.id)
178+
} else {
179+
getLogger('notifications').error(`${name}: Cannot dismiss notification: item is not a vscode.TreeItem`)
180+
}
181+
})
182+
)
183+
}
184+
185+
export type ResourceResponse = Awaited<ReturnType<HttpResourceFetcher['getNewETagContent']>>
186+
187+
export interface NotificationFetcher {
188+
/**
189+
* Fetch notifications from some source. If there is no (new) data to fetch, then the response's
190+
* content value will be undefined.
191+
*
192+
* @param type typeof NotificationType
193+
* @param versionTag last known version of the data aka ETAG. Can be used to determine if the data changed.
194+
*/
195+
fetch(type: NotificationType, versionTag?: string): Promise<ResourceResponse>
196+
}
197+
198+
export class RemoteFetcher implements NotificationFetcher {
199+
public static readonly retryNumber = 5
200+
public static readonly retryIntervalMs = 30000
201+
202+
private readonly startUpEndpoint: string =
203+
'https://idetoolkits-hostedfiles.amazonaws.com/Notifications/VSCode/startup/1.x.json'
204+
private readonly emergencyEndpoint: string =
205+
'https://idetoolkits-hostedfiles.amazonaws.com/Notifications/VSCode/emergency/1.x.json'
206+
207+
constructor(startUpPath?: string, emergencyPath?: string) {
208+
this.startUpEndpoint = startUpPath ?? this.startUpEndpoint
209+
this.emergencyEndpoint = emergencyPath ?? this.emergencyEndpoint
210+
}
211+
212+
fetch(category: NotificationType, versionTag?: string): Promise<ResourceResponse> {
213+
const endpoint = category === 'startUp' ? this.startUpEndpoint : this.emergencyEndpoint
214+
const fetcher = new HttpResourceFetcher(endpoint, {
215+
showUrl: true,
216+
})
217+
getLogger('notifications').verbose(
218+
'Attempting to fetch notifications for category: %s at endpoint: %s',
219+
category,
220+
endpoint
221+
)
222+
223+
return withRetries(async () => await fetcher.getNewETagContent(versionTag), {
224+
maxRetries: RemoteFetcher.retryNumber,
225+
delay: RemoteFetcher.retryIntervalMs,
226+
// No exponential backoff - necessary?
227+
})
228+
}
229+
}
230+
231+
/**
232+
* Can be used when developing locally. This may be expanded at some point to allow notifications
233+
* to be published via github rather than internally.
234+
*
235+
* versionTag (ETAG) is ignored.
236+
*/
237+
export class LocalFetcher implements NotificationFetcher {
238+
// Paths relative to running extension root folder (e.g. packages/amazonq/).
239+
private readonly startUpLocalPath: string = '../core/src/test/notifications/resources/startup/1.x.json'
240+
private readonly emergencyLocalPath: string = '../core/src/test/notifications/resources/emergency/1.x.json'
241+
242+
constructor(startUpPath?: string, emergencyPath?: string) {
243+
this.startUpLocalPath = startUpPath ?? this.startUpLocalPath
244+
this.emergencyLocalPath = emergencyPath ?? this.emergencyLocalPath
245+
}
246+
247+
async fetch(category: NotificationType, versionTag?: string): Promise<ResourceResponse> {
248+
const uri = category === 'startUp' ? this.startUpLocalPath : this.emergencyLocalPath
249+
getLogger('notifications').verbose(
250+
'Attempting to fetch notifications locally for category: %s at path: %s',
251+
category,
252+
uri
253+
)
254+
255+
return {
256+
content: await new FileResourceFetcher(globals.context.asAbsolutePath(uri)).get(),
257+
eTag: 'LOCAL_PATH',
258+
}
259+
}
260+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
export { RuleContext } from './types'
7+
export { NotificationsController } from './controller'
8+
export { RuleEngine } from './rules'
9+
export { registerProvider, NotificationsNode } from './panelNode'

0 commit comments

Comments
 (0)