From 6ef0a4749ad18614650dd958168900b5caf2d7e9 Mon Sep 17 00:00:00 2001 From: Aidan Ton Date: Tue, 1 Apr 2025 15:48:31 -0700 Subject: [PATCH 01/17] /review: automatically generate fix without clicking Generate Fix button --- ...e-77c028a2-092e-4ee6-b782-d6ddc930f304.json | 4 ++++ .../codewhisperer/commands/basicCommands.ts | 18 ++++++++++++------ .../core/src/shared/settings-amazonq.gen.ts | 4 +--- 3 files changed, 17 insertions(+), 9 deletions(-) create mode 100644 packages/amazonq/.changes/next-release/Feature-77c028a2-092e-4ee6-b782-d6ddc930f304.json diff --git a/packages/amazonq/.changes/next-release/Feature-77c028a2-092e-4ee6-b782-d6ddc930f304.json b/packages/amazonq/.changes/next-release/Feature-77c028a2-092e-4ee6-b782-d6ddc930f304.json new file mode 100644 index 00000000000..1ffed6ea405 --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-77c028a2-092e-4ee6-b782-d6ddc930f304.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "/review: automatically generate fix without clicking Generate Fix button" +} diff --git a/packages/core/src/codewhisperer/commands/basicCommands.ts b/packages/core/src/codewhisperer/commands/basicCommands.ts index e94df2371d2..d7831be9584 100644 --- a/packages/core/src/codewhisperer/commands/basicCommands.ts +++ b/packages/core/src/codewhisperer/commands/basicCommands.ts @@ -372,6 +372,9 @@ export const openSecurityIssuePanel = Commands.declare( undefined, !!targetIssue.suggestedFixes.length ) + if (targetIssue.suggestedFixes.length === 0) { + await generateFix.execute(targetIssue, targetFilePath, 'webview', true, false) + } } ) @@ -665,7 +668,8 @@ export const generateFix = Commands.declare( issue: CodeScanIssue | IssueItem | undefined, filePath: string, source: Component, - refresh: boolean = false + refresh: boolean = false, + shouldOpenSecurityIssuePanel: boolean = true ) => { const targetIssue: CodeScanIssue | undefined = issue instanceof IssueItem ? issue.issue : issue const targetFilePath: string = issue instanceof IssueItem ? issue.filePath : filePath @@ -679,11 +683,13 @@ export const generateFix = Commands.declare( } await telemetry.codewhisperer_codeScanIssueGenerateFix.run(async () => { try { - await vscode.commands - .executeCommand('aws.amazonq.openSecurityIssuePanel', targetIssue, targetFilePath) - .then(undefined, (e) => { - getLogger().error('Failed to open security issue panel: %s', e.message) - }) + if (shouldOpenSecurityIssuePanel) { + await vscode.commands + .executeCommand('aws.amazonq.openSecurityIssuePanel', targetIssue, targetFilePath) + .then(undefined, (e) => { + getLogger().error('Failed to open security issue panel: %s', e.message) + }) + } await updateSecurityIssueWebview({ isGenerateFixLoading: true, // eslint-disable-next-line unicorn/no-null diff --git a/packages/core/src/shared/settings-amazonq.gen.ts b/packages/core/src/shared/settings-amazonq.gen.ts index 9447f43d12b..25d99235c52 100644 --- a/packages/core/src/shared/settings-amazonq.gen.ts +++ b/packages/core/src/shared/settings-amazonq.gen.ts @@ -18,9 +18,7 @@ export const amazonqSettings = { "amazonQWelcomePage": {}, "amazonQSessionConfigurationMessage": {}, "minIdeVersion": {}, - "ssoCacheError": {}, - "AmazonQLspManifestMessage": {}, - "AmazonQ-WorkspaceLspManifestMessage":{} + "ssoCacheError": {} }, "amazonQ.showCodeWithReferences": {}, "amazonQ.allowFeatureDevelopmentToRunCodeAndTests": {}, From 2da999c2a8bf7f9011d146d5beb33c6febfc7d0e Mon Sep 17 00:00:00 2001 From: Avi Alpert <131792194+avi-alpert@users.noreply.github.com> Date: Wed, 2 Apr 2025 12:34:43 -0400 Subject: [PATCH 02/17] feat(amazonq): conversation persistence, view/search chat history #6893 ## Problem Users lose all chats when they close VSCode, and there's no way to browse through chat history. Users also cant export their conversations to an easily shareable format. ## Solution Automatically persist conversations to JSON files in `~/.aws/amazonq/history`, one for each workspace where Amazon Q chats occur. Add chat history and chat export buttons to top of Amazon Q toolbar. Clicking on the chat history button allows users to browse and search through chat history. Users click on an old conversation to open it back up (currently open conversations are in bold). Clicking on chat export button allows users to save chat transcript as a markdown or html. Note: persistence + history is only for Q Chat Tabs (not /dev, /doc, /transform, etc.) --- package-lock.json | 13 + ...-2cf2317a-aa6e-4413-aa57-e0d8905ab109.json | 4 + ...-80e49868-8e04-4648-aace-ad34ef57eba2.json | 4 + ...-ed1e1b2e-0073-4de0-99b8-e417298328e6.json | 4 + packages/core/package.json | 2 + .../amazonq/webview/ui/apps/baseConnector.ts | 82 +++++- .../webview/ui/apps/docChatConnector.ts | 3 - .../ui/apps/featureDevChatConnector.ts | 3 - .../core/src/amazonq/webview/ui/commands.ts | 7 + .../core/src/amazonq/webview/ui/connector.ts | 23 +- .../ui/detailedList/detailedListConnector.ts | 105 +++++++ packages/core/src/amazonq/webview/ui/main.ts | 35 ++- packages/core/src/codewhispererChat/app.ts | 41 ++- .../codewhispererChat/clients/chat/v0/chat.ts | 4 + .../controllers/chat/chatRequest/converter.ts | 27 +- .../controllers/chat/controller.ts | 48 ++++ .../controllers/chat/messenger/messenger.ts | 44 ++- .../controllers/chat/model.ts | 15 +- .../controllers/chat/tabBarController.ts | 179 ++++++++++++ .../view/connector/connector.ts | 145 +++++++++- .../view/messages/messageListener.ts | 55 ++++ packages/core/src/shared/db/chatDb/chatDb.ts | 259 ++++++++++++++++++ packages/core/src/shared/db/chatDb/util.ts | 223 +++++++++++++++ 23 files changed, 1308 insertions(+), 17 deletions(-) create mode 100644 packages/amazonq/.changes/next-release/Feature-2cf2317a-aa6e-4413-aa57-e0d8905ab109.json create mode 100644 packages/amazonq/.changes/next-release/Feature-80e49868-8e04-4648-aace-ad34ef57eba2.json create mode 100644 packages/amazonq/.changes/next-release/Feature-ed1e1b2e-0073-4de0-99b8-e417298328e6.json create mode 100644 packages/core/src/amazonq/webview/ui/detailedList/detailedListConnector.ts create mode 100644 packages/core/src/codewhispererChat/controllers/chat/tabBarController.ts create mode 100644 packages/core/src/shared/db/chatDb/chatDb.ts create mode 100644 packages/core/src/shared/db/chatDb/util.ts diff --git a/package-lock.json b/package-lock.json index bbe63640e9c..38131b6a7c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14588,6 +14588,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lokijs": { + "version": "1.5.14", + "resolved": "https://registry.npmjs.org/@types/lokijs/-/lokijs-1.5.14.tgz", + "integrity": "sha512-4Fic47BX3Qxr8pd12KT6/T1XWU8dOlJBIp1jGoMbaDbiEvdv50rAii+B3z1b/J2pvMywcVP+DBPGP5/lgLOKGA==", + "dev": true + }, "node_modules/@types/markdown-it": { "version": "13.0.2", "dev": true, @@ -21157,6 +21163,11 @@ "triple-beam": "^1.3.0" } }, + "node_modules/lokijs": { + "version": "1.5.12", + "resolved": "https://registry.npmjs.org/lokijs/-/lokijs-1.5.12.tgz", + "integrity": "sha512-Q5ALD6JiS6xAUWCwX3taQmgwxyveCtIIuL08+ml0nHwT3k0S/GIFJN+Hd38b1qYIMaE5X++iqsqWVksz7SYW+Q==" + }, "node_modules/long": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/long/-/long-5.3.1.tgz", @@ -26777,6 +26788,7 @@ "js-yaml": "^4.1.0", "jsonc-parser": "^3.2.0", "lodash": "^4.17.21", + "lokijs": "^1.5.12", "markdown-it": "^13.0.2", "mime-types": "^2.1.32", "node-fetch": "^2.7.0", @@ -26814,6 +26826,7 @@ "@types/js-yaml": "^4.0.5", "@types/jsdom": "^21.1.6", "@types/lodash": "^4.14.180", + "@types/lokijs": "^1.5.14", "@types/markdown-it": "^13.0.2", "@types/mime-types": "^2.1.4", "@types/mocha": "^10.0.6", diff --git a/packages/amazonq/.changes/next-release/Feature-2cf2317a-aa6e-4413-aa57-e0d8905ab109.json b/packages/amazonq/.changes/next-release/Feature-2cf2317a-aa6e-4413-aa57-e0d8905ab109.json new file mode 100644 index 00000000000..bbe5023b9e4 --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-2cf2317a-aa6e-4413-aa57-e0d8905ab109.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "Amazon Q chat: View and search chat history" +} diff --git a/packages/amazonq/.changes/next-release/Feature-80e49868-8e04-4648-aace-ad34ef57eba2.json b/packages/amazonq/.changes/next-release/Feature-80e49868-8e04-4648-aace-ad34ef57eba2.json new file mode 100644 index 00000000000..4fa38b0c059 --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-80e49868-8e04-4648-aace-ad34ef57eba2.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "Amazon Q chat: Automatically persist chats between IDE sessions" +} diff --git a/packages/amazonq/.changes/next-release/Feature-ed1e1b2e-0073-4de0-99b8-e417298328e6.json b/packages/amazonq/.changes/next-release/Feature-ed1e1b2e-0073-4de0-99b8-e417298328e6.json new file mode 100644 index 00000000000..11b844c5788 --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-ed1e1b2e-0073-4de0-99b8-e417298328e6.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "Amazon Q chat: Click share icon to export chat to Markdown or HTML" +} diff --git a/packages/core/package.json b/packages/core/package.json index 3990a12f3d0..a2bd239431d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -455,6 +455,7 @@ "@types/js-yaml": "^4.0.5", "@types/jsdom": "^21.1.6", "@types/lodash": "^4.14.180", + "@types/lokijs": "^1.5.14", "@types/markdown-it": "^13.0.2", "@types/mime-types": "^2.1.4", "@types/mocha": "^10.0.6", @@ -555,6 +556,7 @@ "js-yaml": "^4.1.0", "jsonc-parser": "^3.2.0", "lodash": "^4.17.21", + "lokijs": "^1.5.12", "markdown-it": "^13.0.2", "mime-types": "^2.1.32", "node-fetch": "^2.7.0", diff --git a/packages/core/src/amazonq/webview/ui/apps/baseConnector.ts b/packages/core/src/amazonq/webview/ui/apps/baseConnector.ts index 3d5cdd71473..10d0aba9f26 100644 --- a/packages/core/src/amazonq/webview/ui/apps/baseConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/baseConnector.ts @@ -3,12 +3,21 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ChatItem, ChatItemAction, ChatItemType, FeedbackPayload, QuickActionCommand } from '@aws/mynah-ui' +import { + ChatItem, + ChatItemAction, + ChatItemType, + DetailedList, + FeedbackPayload, + QuickActionCommand, +} from '@aws/mynah-ui' import { ExtensionMessage } from '../commands' import { CodeReference } from './amazonqCommonsConnector' import { TabOpenType, TabsStorage, TabType } from '../storages/tabsStorage' import { FollowUpGenerator } from '../followUps/generator' import { CWCChatItem } from '../connector' +import { DetailedListSheetProps } from '@aws/mynah-ui/dist/components/detailed-list/detailed-list-sheet' +import { DetailedListConnector, DetailedListType } from '../detailedList/detailedListConnector' interface ChatPayload { chatMessage: string @@ -23,6 +32,15 @@ export interface BaseConnectorProps { onError: (tabID: string, message: string, title: string) => void onWarning: (tabID: string, message: string, title: string) => void onOpenSettingsMessage: (tabID: string) => void + onNewTab: (tabType: TabType, chats?: ChatItem[]) => string | undefined + onOpenDetailedList: (data: DetailedListSheetProps) => { + update: (data: DetailedList) => void + close: () => void + changeTarget: (direction: 'up' | 'down', snapOnLastAndFirst?: boolean) => void + getTargetElementId: () => string | undefined + } + onSelectTab: (tabID: string, eventID: string) => void + onExportChat: (tabId: string, format: 'html' | 'markdown') => string tabsStorage: TabsStorage } @@ -32,8 +50,13 @@ export abstract class BaseConnector { protected readonly onWarning protected readonly onChatAnswerReceived protected readonly onOpenSettingsMessage + protected readonly onNewTab + protected readonly onOpenDetailedList + protected readonly onExportChat + protected readonly onSelectTab protected readonly followUpGenerator: FollowUpGenerator protected readonly tabsStorage + protected historyConnector abstract getTabType(): TabType @@ -43,8 +66,17 @@ export abstract class BaseConnector { this.onWarning = props.onWarning this.onError = props.onError this.onOpenSettingsMessage = props.onOpenSettingsMessage + this.onNewTab = props.onNewTab this.tabsStorage = props.tabsStorage + this.onOpenDetailedList = props.onOpenDetailedList + this.onExportChat = props.onExportChat + this.onSelectTab = props.onSelectTab this.followUpGenerator = new FollowUpGenerator() + this.historyConnector = new DetailedListConnector( + DetailedListType.history, + this.sendMessageToExtension, + this.onOpenDetailedList + ) } onResponseBodyLinkClick = (tabID: string, messageId: string, link: string): void => { @@ -296,5 +328,53 @@ export abstract class BaseConnector { await this.processOpenSettingsMessage(messageData) return } + + if (messageData.type === 'restoreTabMessage') { + const newTabId = this.onNewTab(messageData.tabType, messageData.chats) + this.sendMessageToExtension({ + command: 'tab-restored', + historyId: messageData.historyId, + newTabId, + tabType: this.getTabType(), + exportTab: messageData.exportTab, + }) + return + } + + if (messageData.type === 'updateDetailedListMessage') { + if (messageData.listType === DetailedListType.history) { + this.historyConnector.updateList(messageData.detailedList) + } + return + } + + if (messageData.type === 'closeDetailedListMessage') { + if (messageData.listType === DetailedListType.history) { + this.historyConnector.closeList() + } + return + } + + if (messageData.type === 'openDetailedListMessage') { + if (messageData.listType === DetailedListType.history) { + this.historyConnector.openList(messageData) + } + return + } + + if (messageData.type === 'exportChatMessage') { + const serializedChat = this.onExportChat(messageData.tabID, messageData.format) + this.sendMessageToExtension({ + command: 'save-chat', + uri: messageData.uri, + serializedChat, + tabType: 'cwc', + }) + return + } + + if (messageData.type === 'selectTabMessage') { + this.onSelectTab(messageData.tabID, messageData.eventID) + } } } diff --git a/packages/core/src/amazonq/webview/ui/apps/docChatConnector.ts b/packages/core/src/amazonq/webview/ui/apps/docChatConnector.ts index efabe2be4f5..96822c8336c 100644 --- a/packages/core/src/amazonq/webview/ui/apps/docChatConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/docChatConnector.ts @@ -23,7 +23,6 @@ export interface ConnectorProps extends BaseConnectorProps { onUpdatePromptProgress: (tabID: string, progressField: ProgressField) => void onChatInputEnabled: (tabID: string, enabled: boolean) => void onUpdateAuthentication: (featureDevEnabled: boolean, authenticatingTabIDs: string[]) => void - onNewTab: (tabType: TabType) => void } export class Connector extends BaseConnector { @@ -32,7 +31,6 @@ export class Connector extends BaseConnector { private readonly updatePlaceholder private readonly chatInputEnabled private readonly onUpdateAuthentication - private readonly onNewTab private readonly updatePromptProgress override getTabType(): TabType { @@ -46,7 +44,6 @@ export class Connector extends BaseConnector { this.updatePlaceholder = props.onUpdatePlaceholder this.chatInputEnabled = props.onChatInputEnabled this.onUpdateAuthentication = props.onUpdateAuthentication - this.onNewTab = props.onNewTab this.updatePromptProgress = props.onUpdatePromptProgress } diff --git a/packages/core/src/amazonq/webview/ui/apps/featureDevChatConnector.ts b/packages/core/src/amazonq/webview/ui/apps/featureDevChatConnector.ts index 69eb9f1c716..1f6d33a1ec4 100644 --- a/packages/core/src/amazonq/webview/ui/apps/featureDevChatConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/featureDevChatConnector.ts @@ -30,7 +30,6 @@ export interface ConnectorProps extends BaseConnectorProps { onUpdatePlaceholder: (tabID: string, newPlaceholder: string) => void onChatInputEnabled: (tabID: string, enabled: boolean) => void onUpdateAuthentication: (featureDevEnabled: boolean, authenticatingTabIDs: string[]) => void - onNewTab: (tabType: TabType) => void } export class Connector extends BaseConnector { @@ -40,7 +39,6 @@ export class Connector extends BaseConnector { private readonly updatePlaceholder private readonly chatInputEnabled private readonly onUpdateAuthentication - private readonly onNewTab override getTabType(): TabType { return 'featuredev' @@ -53,7 +51,6 @@ export class Connector extends BaseConnector { this.updatePlaceholder = props.onUpdatePlaceholder this.chatInputEnabled = props.onChatInputEnabled this.onUpdateAuthentication = props.onUpdateAuthentication - this.onNewTab = props.onNewTab this.onChatAnswerUpdated = props.onChatAnswerUpdated } diff --git a/packages/core/src/amazonq/webview/ui/commands.ts b/packages/core/src/amazonq/webview/ui/commands.ts index 5b79549e53e..5343c84b7f5 100644 --- a/packages/core/src/amazonq/webview/ui/commands.ts +++ b/packages/core/src/amazonq/webview/ui/commands.ts @@ -44,5 +44,12 @@ type MessageCommand = | 'update-welcome-count' | 'quick-command-group-action-click' | 'context-selected' + | 'tab-restored' + | 'tab-bar-button-clicked' + | 'export-chat' + | 'save-chat' + | 'detailed-list-filter-change' + | 'detailed-list-item-select' + | 'detailed-list-action-click' export type ExtensionMessage = Record & { command: MessageCommand } diff --git a/packages/core/src/amazonq/webview/ui/connector.ts b/packages/core/src/amazonq/webview/ui/connector.ts index 8f1cde9e565..1c31f6cc842 100644 --- a/packages/core/src/amazonq/webview/ui/connector.ts +++ b/packages/core/src/amazonq/webview/ui/connector.ts @@ -16,6 +16,7 @@ import { QuickActionCommand, ChatItemFormItem, ChatItemButton, + DetailedList, } from '@aws/mynah-ui' import { Connector as CWChatConnector } from './apps/cwChatConnector' import { Connector as FeatureDevChatConnector } from './apps/featureDevChatConnector' @@ -30,6 +31,7 @@ import { WelcomeFollowupType } from './apps/amazonqCommonsConnector' import { AuthFollowUpType } from './followUps/generator' import { DiffTreeFileInfo } from './diffTree/types' import { UserIntent } from '@amzn/codewhisperer-streaming' +import { DetailedListSheetProps } from '@aws/mynah-ui/dist/components/detailed-list/detailed-list-sheet' export interface CodeReference { licenseName?: string @@ -94,7 +96,7 @@ export interface ConnectorProps { onUpdatePromptProgress: (tabID: string, progressField: ProgressField) => void onChatInputEnabled: (tabID: string, enabled: boolean) => void onUpdateAuthentication: (featureDevEnabled: boolean, authenticatingTabIDs: string[]) => void - onNewTab: (tabType: TabType) => void + onNewTab: (tabType: TabType, chats?: ChatItem[]) => string | undefined onFileActionClick: (tabID: string, messageId: string, filePath: string, actionName: string) => void handleCommand: (chatPrompt: ChatPrompt, tabId: string) => void sendStaticMessages: (tabID: string, messages: ChatItem[]) => void @@ -106,6 +108,14 @@ export interface ConnectorProps { title?: string, description?: string ) => void + onOpenDetailedList: (data: DetailedListSheetProps) => { + update: (data: DetailedList) => void + close: () => void + changeTarget: (direction: 'up' | 'down', snapOnLastAndFirst?: boolean) => void + getTargetElementId: () => string | undefined + } + onSelectTab: (tabID: string, eventID: string) => void + onExportChat: (tabID: string, format: 'markdown' | 'html') => string tabsStorage: TabsStorage } @@ -291,6 +301,7 @@ export class Connector { this.tabsStorage.updateTabLastCommand(messageData.tabID, '') } + // Run when user opens new tab in UI onTabAdd = (tabID: string): void => { this.tabsStorage.addTab({ id: tabID, @@ -684,6 +695,16 @@ export class Connector { return false } + onTabBarButtonClick = async (tabId: string, buttonId: string, eventId?: string) => { + this.sendMessageToExtension({ + command: 'tab-bar-button-clicked', + buttonId, + type: '', + tabID: tabId, + tabType: 'cwc', + }) + } + onCustomFormAction = ( tabId: string, messageId: string | undefined, diff --git a/packages/core/src/amazonq/webview/ui/detailedList/detailedListConnector.ts b/packages/core/src/amazonq/webview/ui/detailedList/detailedListConnector.ts new file mode 100644 index 00000000000..3fabd05816d --- /dev/null +++ b/packages/core/src/amazonq/webview/ui/detailedList/detailedListConnector.ts @@ -0,0 +1,105 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DetailedListItem, ChatItemButton, DetailedList } from '@aws/mynah-ui' +import { ExtensionMessage } from '../commands' +import { DetailedListSheetProps } from '@aws/mynah-ui/dist/components/detailed-list/detailed-list-sheet' + +export enum DetailedListType { + history = 'history', +} +export class DetailedListConnector { + type: DetailedListType + sendMessageToExtension: (message: ExtensionMessage) => void + onOpenDetailedList: (data: DetailedListSheetProps) => { + update: (data: DetailedList) => void + close: () => void + changeTarget: (direction: 'up' | 'down', snapOnLastAndFirst?: boolean) => void + getTargetElementId: () => string | undefined + } + closeList() {} + updateList(_data: DetailedList) {} + changeTarget(_direction: 'up' | 'down', _snapOnLastAndFirst?: boolean) {} + getTargetElementId(): string | undefined { + return undefined + } + + constructor( + type: DetailedListType, + sendMessageToExtension: (message: ExtensionMessage) => void, + onOpenDetailedList: (data: DetailedListSheetProps) => { + update: (data: DetailedList) => void + close: () => void + changeTarget: (direction: 'up' | 'down', snapOnLastAndFirst?: boolean) => void + getTargetElementId: () => string | undefined + } + ) { + this.type = type + this.sendMessageToExtension = sendMessageToExtension + this.onOpenDetailedList = onOpenDetailedList + } + + openList(messageData: any) { + const { update, close, changeTarget, getTargetElementId } = this.onOpenDetailedList({ + tabId: messageData.tabID, + detailedList: messageData.detailedList, + events: { + onFilterValueChange: this.onFilterValueChange, + onKeyPress: this.onKeyPress, + onItemSelect: this.onItemSelect, + onActionClick: this.onActionClick, + }, + }) + this.closeList = close + this.updateList = update + this.changeTarget = changeTarget + this.getTargetElementId = getTargetElementId + } + + onFilterValueChange = (filterValues: Record, isValid: boolean) => { + this.sendMessageToExtension({ + command: 'detailed-list-filter-change', + tabType: 'cwc', + listType: this.type, + filterValues, + isValid, + }) + } + + onItemSelect = (detailedListItem: DetailedListItem) => { + this.sendMessageToExtension({ + command: 'detailed-list-item-select', + tabType: 'cwc', + listType: this.type, + item: detailedListItem, + }) + } + + onActionClick = (action: ChatItemButton) => { + this.sendMessageToExtension({ + command: 'detailed-list-action-click', + tabType: 'cwc', + listType: this.type, + action, + }) + } + + onKeyPress = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + this.closeList() + } else if (e.key === 'Enter') { + const targetElementId = this.getTargetElementId() + if (targetElementId) { + this.onItemSelect({ + id: targetElementId, + }) + } + } else if (e.key === 'ArrowUp') { + this.changeTarget('up') + } else if (e.key === 'ArrowDown') { + this.changeTarget('down') + } + } +} diff --git a/packages/core/src/amazonq/webview/ui/main.ts b/packages/core/src/amazonq/webview/ui/main.ts index 195f8fc7dfe..5f1254dc3b1 100644 --- a/packages/core/src/amazonq/webview/ui/main.ts +++ b/packages/core/src/amazonq/webview/ui/main.ts @@ -34,6 +34,7 @@ import { welcomeScreenTabData } from './walkthrough/welcome' import { agentWalkthroughDataModel } from './walkthrough/agent' import { createClickTelemetry, createOpenAgentTelemetry } from './telemetry/actions' import { disclaimerAcknowledgeButtonId, disclaimerCard } from './texts/disclaimer' +import { DetailedListSheetProps } from '@aws/mynah-ui/dist/components/detailed-list/detailed-list-sheet' /** * The number of welcome chat tabs that can be opened before the NEXT one will become @@ -238,6 +239,18 @@ export const createMynahUI = ( } } }, + onOpenDetailedList: (data: DetailedListSheetProps) => { + return mynahUI.openDetailedList(data) + }, + onSelectTab: (tabID: string, eventID: string) => { + mynahUI.selectTab(tabID, eventID || '') + }, + onExportChat: (tabID: string, format: 'markdown' | 'html'): string => { + if (tabID) { + return mynahUI.serializeChat(tabID, format) + } + return '' + }, onFileActionClick: (tabID: string, messageId: string, filePath: string, actionName: string): void => {}, onQuickHandlerCommand: (tabID: string, command?: string, eventId?: string) => { tabsStorage.updateTabLastCommand(tabID, command) @@ -533,18 +546,21 @@ export const createMynahUI = ( promptInputPlaceholder: newPlaceholder, }) }, - onNewTab(tabType: TabType) { + onNewTab(tabType: TabType, chats?: ChatItem[]) { const newTabID = mynahUI.updateStore('', {}) if (!newTabID) { return } - tabsStorage.updateTabTypeFromUnknown(newTabID, tabType) connector.onKnownTabOpen(newTabID) connector.onUpdateTabType(newTabID) - mynahUI.updateStore(newTabID, tabDataGenerator.getTabData(tabType, true)) + mynahUI.updateStore(newTabID, { + ...tabDataGenerator.getTabData(tabType, true), + ...(chats ? { chatItems: chats } : {}), + }) featureConfigs = tryNewMap(featureConfigsSerialized) + return newTabID }, onOpenSettingsMessage(tabId: string) { mynahUI.addChatItem(tabId, { @@ -714,6 +730,7 @@ export const createMynahUI = ( }, onQuickCommandGroupActionClick: connector.onQuickCommandGroupActionClick, onContextSelected: connector.onContextSelected, + onTabBarButtonClick: connector.onTabBarButtonClick, onVote: connector.onChatItemVoted, onInBodyButtonClicked: (tabId, messageId, action, eventId) => { switch (action.id) { @@ -946,6 +963,18 @@ export const createMynahUI = ( maxTabs: 10, feedbackOptions: feedbackOptions, texts: uiComponentsTexts, + tabBarButtons: [ + { + id: 'history_sheet', + icon: MynahIcons.COMMENT, + description: 'View chat history', + }, + { + id: 'export_chat', + icon: MynahIcons.EXTERNAL, + description: 'Export chat', + }, + ], }, }) diff --git a/packages/core/src/codewhispererChat/app.ts b/packages/core/src/codewhispererChat/app.ts index 3ffa51b75eb..63147002339 100644 --- a/packages/core/src/codewhispererChat/app.ts +++ b/packages/core/src/codewhispererChat/app.ts @@ -28,9 +28,17 @@ import { AcceptDiff, QuickCommandGroupActionClick, FileClick, + TabBarButtonClick, + SaveChatMessage, } from './controllers/chat/model' import { EditorContextCommand, registerCommands } from './commands/registerCommands' -import { ContextSelectedMessage, CustomFormActionMessage } from './view/connector/connector' +import { + ContextSelectedMessage, + CustomFormActionMessage, + DetailedListActionClickMessage, + DetailedListFilterChangeMessage, + DetailedListItemSelectMessage, +} from './view/connector/connector' export function init(appContext: AmazonQAppInitContext) { const cwChatControllerEventEmitters = { @@ -56,6 +64,11 @@ export function init(appContext: AmazonQAppInitContext) { processCustomFormAction: new EventEmitter(), processContextSelected: new EventEmitter(), processFileClick: new EventEmitter(), + processTabBarButtonClick: new EventEmitter(), + processSaveChat: new EventEmitter(), + processDetailedListFilterChangeMessage: new EventEmitter(), + processDetailedListItemSelectMessage: new EventEmitter(), + processDetailedListActionClickMessage: new EventEmitter(), } const cwChatControllerMessageListeners = { @@ -117,6 +130,19 @@ export function init(appContext: AmazonQAppInitContext) { cwChatControllerEventEmitters.processContextSelected ), processFileClick: new MessageListener(cwChatControllerEventEmitters.processFileClick), + processTabBarButtonClick: new MessageListener( + cwChatControllerEventEmitters.processTabBarButtonClick + ), + processSaveChat: new MessageListener(cwChatControllerEventEmitters.processSaveChat), + processDetailedListFilterChangeMessage: new MessageListener( + cwChatControllerEventEmitters.processDetailedListFilterChangeMessage + ), + processDetailedListItemSelectMessage: new MessageListener( + cwChatControllerEventEmitters.processDetailedListItemSelectMessage + ), + processDetailedListActionClickMessage: new MessageListener( + cwChatControllerEventEmitters.processDetailedListActionClickMessage + ), } const cwChatControllerMessagePublishers = { @@ -180,6 +206,19 @@ export function init(appContext: AmazonQAppInitContext) { cwChatControllerEventEmitters.processContextSelected ), processFileClick: new MessagePublisher(cwChatControllerEventEmitters.processFileClick), + processTabBarButtonClick: new MessagePublisher( + cwChatControllerEventEmitters.processTabBarButtonClick + ), + processSaveChat: new MessagePublisher(cwChatControllerEventEmitters.processSaveChat), + processDetailedListActionClickMessage: new MessagePublisher( + cwChatControllerEventEmitters.processDetailedListActionClickMessage + ), + processDetailedListFilterChangeMessage: new MessagePublisher( + cwChatControllerEventEmitters.processDetailedListFilterChangeMessage + ), + processDetailedListItemSelectMessage: new MessagePublisher( + cwChatControllerEventEmitters.processDetailedListItemSelectMessage + ), } new CwChatController( diff --git a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts index 3cf030b9b8e..c32f67cdac5 100644 --- a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts +++ b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts @@ -13,6 +13,10 @@ import { UserWrittenCodeTracker } from '../../../../codewhisperer/tracker/userWr export class ChatSession { private sessionId?: string + /** + * True if messages from local history have been sent to session. + */ + localHistoryHydrated: boolean = false contexts: Map = new Map() // TODO: doesn't handle the edge case when two files share the same relativePath string but from different root diff --git a/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts b/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts index be286122dc6..896d597f796 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts @@ -3,9 +3,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ConversationState, CursorState, DocumentSymbol, SymbolType, TextDocument } from '@amzn/codewhisperer-streaming' +import { + ConversationState, + CursorState, + DocumentSymbol, + SymbolType, + TextDocument, + ChatMessage, +} from '@amzn/codewhisperer-streaming' import { AdditionalContentEntryAddition, ChatTriggerType, RelevantTextDocumentAddition, TriggerPayload } from '../model' import { undefinedIfEmpty } from '../../../../shared/utilities/textUtilities' +import { ChatItemType } from '../../../../amazonq/commons/model' import { getLogger } from '../../../../shared/logger/logger' const fqnNameSizeDownLimit = 1 @@ -147,6 +155,22 @@ export function triggerPayloadToChatRequest(triggerPayload: TriggerPayload): { c // service will throw validation exception if string is empty const customizationArn: string | undefined = undefinedIfEmpty(triggerPayload.customization.arn) const chatTriggerType = triggerPayload.trigger === ChatTriggerType.InlineChatMessage ? 'INLINE_CHAT' : 'MANUAL' + const history = + triggerPayload.history && + triggerPayload.history.length > 0 && + (triggerPayload.history.map((chat) => + chat.type === ('answer' as ChatItemType) + ? { + assistantResponseMessage: { + content: chat.body, + }, + } + : { + userInputMessage: { + content: chat.body, + }, + } + ) as ChatMessage[]) return { conversationState: { @@ -167,6 +191,7 @@ export function triggerPayloadToChatRequest(triggerPayload: TriggerPayload): { c }, chatTriggerType, customizationArn: customizationArn, + history: history || undefined, }, } } diff --git a/packages/core/src/codewhispererChat/controllers/chat/controller.ts b/packages/core/src/codewhispererChat/controllers/chat/controller.ts index a37ce3acdcd..b9c7bac8ada 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/controller.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/controller.ts @@ -32,11 +32,16 @@ import { DocumentReference, FileClick, RelevantTextDocumentAddition, + TabBarButtonClick, + SaveChatMessage, } from './model' import { AppToWebViewMessageDispatcher, ContextSelectedMessage, CustomFormActionMessage, + DetailedListActionClickMessage, + DetailedListFilterChangeMessage, + DetailedListItemSelectMessage, } from '../../view/connector/connector' import { MessagePublisher } from '../../../amazonq/messages/messagePublisher' import { MessageListener } from '../../../amazonq/messages/messageListener' @@ -81,6 +86,8 @@ import { defaultContextLengths, } from '../../constants' import { ChatSession } from '../../clients/chat/v0/chat' +import { Database } from '../../../shared/db/chatDb/chatDb' +import { TabBarController } from './tabBarController' export interface ChatControllerMessagePublishers { readonly processPromptChatMessage: MessagePublisher @@ -105,6 +112,11 @@ export interface ChatControllerMessagePublishers { readonly processCustomFormAction: MessagePublisher readonly processContextSelected: MessagePublisher readonly processFileClick: MessagePublisher + readonly processTabBarButtonClick: MessagePublisher + readonly processSaveChat: MessagePublisher + readonly processDetailedListFilterChangeMessage: MessagePublisher + readonly processDetailedListItemSelectMessage: MessagePublisher + readonly processDetailedListActionClickMessage: MessagePublisher } export interface ChatControllerMessageListeners { @@ -130,6 +142,11 @@ export interface ChatControllerMessageListeners { readonly processCustomFormAction: MessageListener readonly processContextSelected: MessageListener readonly processFileClick: MessageListener + readonly processTabBarButtonClick: MessageListener + readonly processSaveChat: MessageListener + readonly processDetailedListFilterChangeMessage: MessageListener + readonly processDetailedListItemSelectMessage: MessageListener + readonly processDetailedListActionClickMessage: MessageListener } export class ChatController { @@ -138,10 +155,12 @@ export class ChatController { private readonly messenger: Messenger private readonly editorContextExtractor: EditorContextExtractor private readonly editorContentController: EditorContentController + private readonly tabBarController: TabBarController private readonly promptGenerator: PromptsGenerator private readonly userIntentRecognizer: UserIntentRecognizer private readonly telemetryHelper: CWCTelemetryHelper private userPromptsWatcher: vscode.FileSystemWatcher | undefined + private chatHistoryDb = Database.getInstance() public constructor( private readonly chatControllerMessageListeners: ChatControllerMessageListeners, @@ -159,6 +178,7 @@ export class ChatController { this.editorContentController = new EditorContentController() this.promptGenerator = new PromptsGenerator() this.userIntentRecognizer = new UserIntentRecognizer() + this.tabBarController = new TabBarController(this.messenger) onDidChangeAmazonQVisibility((visible) => { if (visible) { @@ -263,6 +283,21 @@ export class ChatController { this.chatControllerMessageListeners.processFileClick.onMessage((data) => { return this.processFileClickMessage(data) }) + this.chatControllerMessageListeners.processTabBarButtonClick.onMessage((data) => { + return this.tabBarController.processTabBarButtonClick(data) + }) + this.chatControllerMessageListeners.processSaveChat.onMessage((data) => { + return this.tabBarController.processSaveChat(data) + }) + this.chatControllerMessageListeners.processDetailedListActionClickMessage.onMessage((data) => { + return this.tabBarController.processActionClickMessage(data) + }) + this.chatControllerMessageListeners.processDetailedListFilterChangeMessage.onMessage((data) => { + return this.tabBarController.processFilterChangeMessage(data) + }) + this.chatControllerMessageListeners.processDetailedListItemSelectMessage.onMessage((data) => { + return this.tabBarController.processItemSelectMessage(data) + }) } private registerUserPromptsWatcher() { @@ -398,6 +433,7 @@ export class ChatController { this.sessionStorage.deleteSession(message.tabID) this.triggerEventsStorage.removeTabEvents(message.tabID) // this.telemetryHelper.recordCloseChat(message.tabID) + this.chatHistoryDb.updateTabOpenState(message.tabID, false) } private async processTabChangedMessage(message: TabChangedMessage) { @@ -420,6 +456,7 @@ export class ChatController { private async processContextCommandUpdateMessage() { // when UI is ready, refresh the context commands + this.tabBarController.loadChats() this.registerUserPromptsWatcher() const contextCommand: MynahUIDataModel['contextCommands'] = [ { @@ -808,6 +845,7 @@ export class ChatController { this.sessionStorage.deleteSession(message.tabID) this.triggerEventsStorage.removeTabEvents(message.tabID) recordTelemetryChatRunCommand('clear') + this.chatHistoryDb.clearTab(message.tabID) return default: this.processQuickActionCommand(message) @@ -1076,6 +1114,10 @@ export class ChatController { } const session = this.sessionStorage.getSession(tabID) + if (!session.localHistoryHydrated) { + triggerPayload.history = this.chatHistoryDb.getMessages(triggerEvent.tabID, 10) + session.localHistoryHydrated = true + } await this.resolveContextCommandPayload(triggerPayload, session) triggerPayload.useRelevantDocuments = triggerPayload.context.some( (context) => typeof context !== 'string' && context.command === '@workspace' @@ -1160,6 +1202,12 @@ export class ChatController { } this.telemetryHelper.recordEnterFocusConversation(triggerEvent.tabID) this.telemetryHelper.recordStartConversation(triggerEvent, triggerPayload) + if (session.sessionIdentifier) { + this.chatHistoryDb.addMessage(tabID, 'cwc', session.sessionIdentifier, { + body: triggerPayload.message, + type: 'prompt' as any, + }) + } getLogger().info( `response to tab: ${tabID} conversationID: ${session.sessionIdentifier} requestID: ${ diff --git a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts index dd80676cf8b..15dafb60ffb 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts @@ -10,9 +10,15 @@ import { CodeReference, ContextCommandData, EditorContextCommandMessage, + ExportChatMessage, OpenSettingsMessage, + OpenDetailedListMessage, QuickActionMessage, + RestoreTabMessage, ShowCustomFormMessage, + UpdateDetailedListMessage, + CloseDetailedListMessage, + SelectTabMessage, } from '../../../view/connector/connector' import { EditorContextCommandType } from '../../../commands/registerCommands' import { ChatResponseStream as qdevChatResponseStream } from '@amzn/amazon-q-developer-streaming-client' @@ -37,7 +43,9 @@ import { LspController } from '../../../../amazonq/lsp/lspController' import { extractCodeBlockLanguage } from '../../../../shared/markdown' import { extractAuthFollowUp } from '../../../../amazonq/util/authUtils' import { helpMessage } from '../../../../amazonq/webview/ui/texts/constants' -import { ChatItemButton, ChatItemFormItem, MynahUIDataModel } from '@aws/mynah-ui' +import { ChatItem, ChatItemButton, ChatItemFormItem, DetailedList, MynahUIDataModel } from '@aws/mynah-ui' +import { Database } from '../../../../shared/db/chatDb/chatDb' +import { TabType } from '../../../../amazonq/webview/ui/storages/tabsStorage' export type StaticTextResponseType = 'quick-action-help' | 'onboarding-help' | 'transform' | 'help' @@ -47,6 +55,8 @@ export type MessengerResponseType = { } export class Messenger { + chatHistoryDb = Database.getInstance() + public constructor( private readonly dispatcher: AppToWebViewMessageDispatcher, private readonly telemetryHelper: CWCTelemetryHelper @@ -267,6 +277,14 @@ export class Messenger { this.telemetryHelper.recordMessageResponseError(triggerPayload, tabID, statusCode ?? 0) }) .finally(async () => { + if (session.sessionIdentifier) { + this.chatHistoryDb.addMessage(tabID, 'cwc', session.sessionIdentifier, { + body: message, + type: 'answer' as any, + codeReference: codeReference as any, + relatedContent: { title: 'Sources', content: relatedSuggestions as any }, + }) + } if ( triggerPayload.relevantTextDocuments && triggerPayload.relevantTextDocuments.length > 0 && @@ -507,6 +525,30 @@ export class Messenger { this.dispatcher.sendOpenSettingsMessage(new OpenSettingsMessage(tabID)) } + public sendRestoreTabMessage(historyId: string, tabType: TabType, chats: ChatItem[], exportTab?: boolean) { + this.dispatcher.sendRestoreTabMessage(new RestoreTabMessage(historyId, tabType, chats, exportTab)) + } + + public sendOpenDetailedListMessage(tabId: string, listType: string, data: DetailedList) { + this.dispatcher.sendOpenDetailedListMessage(new OpenDetailedListMessage(tabId, listType, data)) + } + + public sendUpdateDetailedListMessage(listType: string, data: DetailedList) { + this.dispatcher.sendUpdateDetailedListMessage(new UpdateDetailedListMessage(listType, data)) + } + + public sendCloseDetailedListMessage(listType: string) { + this.dispatcher.sendCloseDetailedListMessage(new CloseDetailedListMessage(listType)) + } + + public sendSerializeTabMessage(tabId: string, uri: string, format: 'html' | 'markdown') { + this.dispatcher.sendSerializeTabMessage(new ExportChatMessage(tabId, format, uri)) + } + + public sendSelectTabMessage(tabId: string, eventID?: string) { + this.dispatcher.sendSelectTabMessage(new SelectTabMessage(tabId, eventID)) + } + public sendContextCommandData(contextCommands: MynahUIDataModel['contextCommands']) { this.dispatcher.sendContextCommandData(new ContextCommandData(contextCommands)) } diff --git a/packages/core/src/codewhispererChat/controllers/chat/model.ts b/packages/core/src/codewhispererChat/controllers/chat/model.ts index 8e13360a486..5c41ba95111 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/model.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/model.ts @@ -10,7 +10,7 @@ import { Selection } from 'vscode' import { TabOpenType } from '../../../amazonq/webview/ui/storages/tabsStorage' import { CodeReference } from '../../view/connector/connector' import { Customization } from '../../../codewhisperer/client/codewhispereruserclient' -import { QuickActionCommand } from '@aws/mynah-ui' +import { ChatItem, QuickActionCommand } from '@aws/mynah-ui' export interface TriggerTabIDReceived { tabID: string @@ -141,6 +141,16 @@ export interface FooterInfoLinkClick { link: string } +export interface TabBarButtonClick { + tabID: string + buttonId: string +} + +export interface SaveChatMessage { + serializedChat: string + uri: string +} + export interface QuickCommandGroupActionClick { command: string actionId: string @@ -186,7 +196,7 @@ export interface TriggerPayload { readonly codeQuery: CodeQuery | undefined readonly userIntent: UserIntent | undefined readonly customization: Customization - readonly context: string[] | QuickActionCommand[] + context: string[] | QuickActionCommand[] relevantTextDocuments: RelevantTextDocumentAddition[] additionalContents: AdditionalContentEntryAddition[] // a reference to all documents used in chat payload @@ -196,6 +206,7 @@ export interface TriggerPayload { traceId?: string contextLengths: ContextLengths workspaceRulesCount?: number + history?: ChatItem[] } export type ContextLengths = { diff --git a/packages/core/src/codewhispererChat/controllers/chat/tabBarController.ts b/packages/core/src/codewhispererChat/controllers/chat/tabBarController.ts new file mode 100644 index 00000000000..ce0970040a2 --- /dev/null +++ b/packages/core/src/codewhispererChat/controllers/chat/tabBarController.ts @@ -0,0 +1,179 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from '../../../shared/fs/fs' +import { + DetailedListActionClickMessage, + DetailedListFilterChangeMessage, + DetailedListItemSelectMessage, +} from '../../view/connector/connector' +import * as vscode from 'vscode' +import { Messenger } from './messenger/messenger' +import { Database } from '../../../shared/db/chatDb/chatDb' +import { TabBarButtonClick, SaveChatMessage } from './model' +import { Conversation, Tab } from '../../../shared/db/chatDb/util' +import { DetailedListItemGroup, MynahIconsType } from '@aws/mynah-ui' + +export class TabBarController { + private readonly messenger: Messenger + private chatHistoryDb = Database.getInstance() + private loadedChats: boolean = false + private searchTimeout: NodeJS.Timeout | undefined = undefined + private readonly DebounceTime = 300 // milliseconds + + constructor(messenger: Messenger) { + this.messenger = messenger + } + async processActionClickMessage(msg: DetailedListActionClickMessage) { + if (msg.listType === 'history') { + if (msg.action.text === 'Delete') { + this.chatHistoryDb.deleteHistory(msg.action.id) + this.messenger.sendUpdateDetailedListMessage('history', { list: this.generateHistoryList() }) + } else if (msg.action.text === 'Export') { + // If conversation is already open, export it + const openTabId = this.chatHistoryDb.getOpenTabId(msg.action.id) + if (openTabId) { + await this.exportChatButtonClicked({ tabID: openTabId, buttonId: 'export_chat' }) + } // If conversation is not open, restore it before exporting + else { + const selectedTab = this.chatHistoryDb.getTab(msg.action.id) + this.restoreTab(selectedTab, true) + } + } + } + } + + async processFilterChangeMessage(msg: DetailedListFilterChangeMessage) { + if (msg.listType === 'history') { + const searchFilter = msg.filterValues['search'] + if (typeof searchFilter !== 'string') { + return + } + + // Clear any pending search + if (this.searchTimeout) { + clearTimeout(this.searchTimeout) + } + + // Set new timeout for this search + this.searchTimeout = setTimeout(() => { + const searchResults = this.chatHistoryDb.searchMessages(searchFilter) + this.messenger.sendUpdateDetailedListMessage('history', { list: searchResults }) + }, this.DebounceTime) + } + } + + // If selected is conversation is already open, select that tab. Else, open new tab with conversation. + processItemSelectMessage(msg: DetailedListItemSelectMessage) { + if (msg.listType === 'history') { + const historyID = msg.item.id + if (historyID) { + const openTabID = this.chatHistoryDb.getOpenTabId(historyID) + if (!openTabID) { + const selectedTab = this.chatHistoryDb.getTab(historyID) + this.restoreTab(selectedTab) + } else { + this.messenger.sendSelectTabMessage(openTabID, historyID) + } + this.messenger.sendCloseDetailedListMessage('history') + } + } + } + + restoreTab(selectedTab?: Tab | null, exportTab?: boolean) { + if (selectedTab) { + this.messenger.sendRestoreTabMessage( + selectedTab.historyId, + selectedTab.tabType, + selectedTab.conversations.flatMap((conv: Conversation) => conv.messages), + exportTab + ) + } + } + + loadChats() { + if (this.loadedChats) { + return + } + this.loadedChats = true + const openConversations = this.chatHistoryDb.getOpenTabs() + if (openConversations) { + for (const conversation of openConversations) { + if (conversation.conversations && conversation.conversations.length > 0) { + this.restoreTab(conversation) + } + } + } + } + + async historyButtonClicked(message: TabBarButtonClick) { + this.messenger.sendOpenDetailedListMessage(message.tabID, 'history', { + header: { title: 'Chat history' }, + filterOptions: [ + { + type: 'textinput', + icon: 'search' as MynahIconsType, + id: 'search', + placeholder: 'Search...', + autoFocus: true, + }, + ], + list: this.generateHistoryList(), + }) + } + + generateHistoryList(): DetailedListItemGroup[] { + const historyItems = this.chatHistoryDb.getHistory() + return historyItems.length > 0 ? historyItems : [{ children: [{ description: 'No chat history found' }] }] + } + + async processSaveChat(message: SaveChatMessage) { + try { + await fs.writeFile(message.uri, message.serializedChat) + } catch (error) { + void vscode.window.showErrorMessage('An error occurred while exporting your chat.') + } + } + + async processTabBarButtonClick(message: TabBarButtonClick) { + switch (message.buttonId) { + case 'history_sheet': + await this.historyButtonClicked(message) + break + case 'export_chat': + await this.exportChatButtonClicked(message) + break + } + } + + private async exportChatButtonClicked(message: TabBarButtonClick) { + const defaultFileName = `q-dev-chat-${new Date().toISOString().split('T')[0]}.md` + const workspaceFolders = vscode.workspace.workspaceFolders + let defaultUri + + if (workspaceFolders && workspaceFolders.length > 0) { + // Use the first workspace folder as root + defaultUri = vscode.Uri.joinPath(workspaceFolders[0].uri, defaultFileName) + } else { + // Fallback if no workspace is open + defaultUri = vscode.Uri.file(defaultFileName) + } + + const saveUri = await vscode.window.showSaveDialog({ + filters: { + Markdown: ['md'], + HTML: ['html'], + }, + defaultUri, + title: 'Export chat', + }) + + if (saveUri) { + // Determine format from file extension + const format = saveUri.fsPath.endsWith('.md') ? 'markdown' : 'html' + this.messenger.sendSerializeTabMessage(message.tabID, saveUri.fsPath, format) + } + } +} diff --git a/packages/core/src/codewhispererChat/view/connector/connector.ts b/packages/core/src/codewhispererChat/view/connector/connector.ts index 0b2b29498c4..b9c1e067b1e 100644 --- a/packages/core/src/codewhispererChat/view/connector/connector.ts +++ b/packages/core/src/codewhispererChat/view/connector/connector.ts @@ -7,8 +7,17 @@ import { Timestamp } from 'aws-sdk/clients/apigateway' import { MessagePublisher } from '../../../amazonq/messages/messagePublisher' import { EditorContextCommandType } from '../../commands/registerCommands' import { AuthFollowUpType } from '../../../amazonq/auth/model' -import { ChatItemButton, ChatItemFormItem, MynahUIDataModel, QuickActionCommand } from '@aws/mynah-ui' +import { + ChatItem, + ChatItemButton, + ChatItemFormItem, + DetailedList, + DetailedListItem, + MynahUIDataModel, + QuickActionCommand, +} from '@aws/mynah-ui' import { DocumentReference } from '../../controllers/chat/model' +import { TabType } from '../../../amazonq/webview/ui/storages/tabsStorage' class UiMessage { readonly time: number = Date.now() @@ -134,6 +143,116 @@ export class OpenSettingsMessage extends UiMessage { override type = 'openSettingsMessage' } +export class RestoreTabMessage extends UiMessage { + override type = 'restoreTabMessage' + readonly tabType: TabType + readonly chats: ChatItem[] + readonly historyId: string + readonly exportTab?: boolean + + constructor(historyId: string, tabType: TabType, chats: ChatItem[], exportTab?: boolean) { + super(undefined) + this.chats = chats + this.tabType = tabType + this.historyId = historyId + this.exportTab = exportTab + } +} + +export class OpenDetailedListMessage extends UiMessage { + override type = 'openDetailedListMessage' + readonly detailedList: DetailedList + listType: string + + constructor(tabID: string, listType: string, data: DetailedList) { + super(tabID) + this.listType = listType + this.detailedList = data + } +} + +export class UpdateDetailedListMessage extends UiMessage { + override type = 'updateDetailedListMessage' + readonly detailedList: DetailedList + listType: string + + constructor(listType: string, data: DetailedList) { + super(undefined) + this.listType = listType + this.detailedList = data + } +} + +export class CloseDetailedListMessage extends UiMessage { + override type = 'closeDetailedListMessage' + listType: string + + constructor(listType: string) { + super(undefined) + this.listType = listType + } +} + +export class ExportChatMessage extends UiMessage { + override type = 'exportChatMessage' + readonly format: 'markdown' | 'html' + readonly uri: string + + constructor(tabID: string, format: 'markdown' | 'html', uri: string) { + super(tabID) + this.format = format + this.uri = uri + } +} + +export class SelectTabMessage extends UiMessage { + override type = 'selectTabMessage' + readonly eventID?: string + + constructor(tabID: string, eventID?: string) { + super(tabID) + this.eventID = eventID + } +} + +export class DetailedListFilterChangeMessage extends UiMessage { + override type = 'detailedListFilterChangeMessage' + readonly listType: string + readonly filterValues: Record + readonly isValid: boolean + + constructor(listType: string, filterValues: Record, isValid: boolean) { + super(undefined) + this.listType = listType + this.filterValues = filterValues + this.isValid = isValid + } +} + +export class DetailedListItemSelectMessage extends UiMessage { + override type = 'detailedListItemSelectMessage' + readonly listType: string + readonly item: DetailedListItem + + constructor(listType: string, item: DetailedListItem) { + super(undefined) + this.listType = listType + this.item = item + } +} + +export class DetailedListActionClickMessage extends UiMessage { + override type = 'detailedListActionClickMessage' + readonly listType: string + action: ChatItemButton + + constructor(listType: string, action: ChatItemButton) { + super(undefined) + this.listType = listType + this.action = action + } +} + export class ContextCommandData extends UiMessage { readonly data: MynahUIDataModel['contextCommands'] override type = 'contextCommandData' @@ -311,6 +430,30 @@ export class AppToWebViewMessageDispatcher { this.appsToWebViewMessagePublisher.publish(message) } + public sendRestoreTabMessage(message: RestoreTabMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + + public sendOpenDetailedListMessage(message: OpenDetailedListMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + + public sendUpdateDetailedListMessage(message: UpdateDetailedListMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + + public sendCloseDetailedListMessage(message: CloseDetailedListMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + + public sendSerializeTabMessage(message: ExportChatMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + + public sendSelectTabMessage(message: SelectTabMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + public sendContextCommandData(message: ContextCommandData) { this.appsToWebViewMessagePublisher.publish(message) } diff --git a/packages/core/src/codewhispererChat/view/messages/messageListener.ts b/packages/core/src/codewhispererChat/view/messages/messageListener.ts index bb2871957c8..8ad59acb0a7 100644 --- a/packages/core/src/codewhispererChat/view/messages/messageListener.ts +++ b/packages/core/src/codewhispererChat/view/messages/messageListener.ts @@ -9,6 +9,7 @@ import { ChatControllerMessagePublishers } from '../../controllers/chat/controll import { ReferenceLogController } from './referenceLogController' import { getLogger } from '../../../shared/logger/logger' import { openSettingsId } from '../../../shared/settings' +import { Database } from '../../../shared/db/chatDb/chatDb' export interface UIMessageListenerProps { readonly chatControllerMessagePublishers: ChatControllerMessagePublishers @@ -119,7 +120,61 @@ export class UIMessageListener { case 'file-click': this.fileClick(msg) break + case 'tab-restored': + this.tabRestored(msg) + break + case 'tab-bar-button-clicked': + this.tabBarButtonClicked(msg) + break + case 'save-chat': + this.saveChat(msg) + break + case 'detailed-list-filter-change': + this.processDetailedListFilterChange(msg) + break + case 'detailed-list-item-select': + this.processDetailedListItemSelect(msg) + break + case 'detailed-list-action-click': + this.processDetailedListActionClick(msg) + break + } + } + + private processDetailedListFilterChange(msg: any) { + this.chatControllerMessagePublishers.processDetailedListFilterChangeMessage.publish(msg) + } + private processDetailedListItemSelect(msg: any) { + this.chatControllerMessagePublishers.processDetailedListItemSelectMessage.publish(msg) + } + private processDetailedListActionClick(msg: any) { + this.chatControllerMessagePublishers.processDetailedListActionClickMessage.publish(msg) + } + + private tabRestored(msg: any) { + const chatHistoryDb = Database.getInstance() + chatHistoryDb.setHistoryIdMapping(msg.newTabId, msg.historyId) + if (msg.exportTab) { + this.chatControllerMessagePublishers.processTabBarButtonClick.publish({ + tabID: msg.newTabId, + buttonId: 'export_chat', + }) } + chatHistoryDb.updateTabOpenState(msg.newTabId, true) + } + + private saveChat(msg: any) { + this.chatControllerMessagePublishers.processSaveChat.publish({ + uri: msg.uri, + serializedChat: msg.serializedChat, + }) + } + + private tabBarButtonClicked(msg: any) { + this.chatControllerMessagePublishers.processTabBarButtonClick.publish({ + tabID: msg.tabID, + buttonId: msg.buttonId, + }) } private processUIIsReady() { diff --git a/packages/core/src/shared/db/chatDb/chatDb.ts b/packages/core/src/shared/db/chatDb/chatDb.ts new file mode 100644 index 00000000000..43c594dc376 --- /dev/null +++ b/packages/core/src/shared/db/chatDb/chatDb.ts @@ -0,0 +1,259 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import Loki from 'lokijs' +import * as vscode from 'vscode' +import { TabType } from '../../../amazonq/webview/ui/storages/tabsStorage' +import { ChatItem, ChatItemType, DetailedListItemGroup } from '@aws/mynah-ui' +import { + ClientType, + Conversation, + FileSystemAdapter, + groupTabsByDate, + Tab, + TabCollection, + updateOrCreateConversation, +} from './util' +import crypto from 'crypto' +import path from 'path' +import { fs } from '../../fs/fs' + +/** + * A singleton database class that manages chat history persistence using LokiJS. + * This class handles storage and retrieval of chat conversations, messages, and tab states + * for the Amazon Q VS Code extension. + * + * The database is stored in the user's home directory under .aws/amazonq/history + * with a unique filename based on the workspace identifier. + * + * + * @singleton + * @class + */ + +export class Database { + private static instance: Database | undefined = undefined + private db: Loki + /** + * Keep track of which open tabs have a corresponding history entry. Maps tabIds to historyIds + */ + private historyIdMapping: Map = new Map() + private dbDirectory: string + initialized: boolean = false + + constructor() { + this.dbDirectory = path.join(fs.getUserHomeDir(), '.aws/amazonq/history') + const workspaceId = this.getWorkspaceIdentifier() + const dbName = `chat-history-${workspaceId}.json` + + this.db = new Loki(dbName, { + adapter: new FileSystemAdapter(this.dbDirectory), + autosave: true, + autoload: true, + autoloadCallback: () => this.databaseInitialize(), + autosaveInterval: 1000, + persistenceMethod: 'fs', + }) + } + + public static getInstance(): Database { + if (!Database.instance) { + Database.instance = new Database() + } + return Database.instance + } + + setHistoryIdMapping(tabId: string, historyId: string) { + this.historyIdMapping.set(tabId, historyId) + } + + getWorkspaceIdentifier() { + // Case 1: .code-workspace file (saved workspace) + const workspace = vscode.workspace.workspaceFile + if (workspace) { + return crypto.createHash('md5').update(workspace.fsPath).digest('hex') + } + + // Case 2: Multi-root workspace (unsaved) + if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 1) { + // Create hash from all folder paths combined + const pathsString = vscode.workspace.workspaceFolders + .map((folder) => folder.uri.fsPath) + .sort() // Sort to ensure consistent hash regardless of folder order + .join('|') + return crypto.createHash('md5').update(pathsString).digest('hex') + } + + // Case 3: Single folder workspace + if (vscode.workspace.workspaceFolders?.[0]) { + return crypto.createHash('md5').update(vscode.workspace.workspaceFolders[0].uri.fsPath).digest('hex') + } + + // Case 4: No workspace + return 'no-workspace' + } + + async databaseInitialize() { + let entries = this.db.getCollection(TabCollection) + if (entries === null) { + entries = this.db.addCollection(TabCollection, { + unique: ['historyId'], + indices: ['updatedAt', 'isOpen'], + }) + } + this.initialized = true + } + + getOpenTabs() { + if (this.initialized) { + const collection = this.db.getCollection(TabCollection) + return collection.find({ isOpen: true }) + } + } + + getTab(historyId: string) { + if (this.initialized) { + const collection = this.db.getCollection(TabCollection) + return collection.findOne({ historyId }) + } + } + + // If conversation is open, return its tabId, else return undefined + getOpenTabId(historyId: string) { + const selectedTab = this.getTab(historyId) + if (selectedTab?.isOpen) { + for (const [tabId, id] of this.historyIdMapping) { + if (id === historyId) { + return tabId + } + } + } + return undefined + } + + clearTab(tabId: string) { + if (this.initialized) { + const tabCollection = this.db.getCollection(TabCollection) + const historyId = this.historyIdMapping.get(tabId) + if (historyId) { + tabCollection.findAndRemove({ historyId }) + } + this.historyIdMapping.delete(tabId) + } + } + + updateTabOpenState(tabId: string, isOpen: boolean) { + if (this.initialized) { + const tabCollection = this.db.getCollection(TabCollection) + const historyId = this.historyIdMapping.get(tabId) + if (historyId) { + tabCollection.findAndUpdate({ historyId }, (tab: Tab) => { + tab.isOpen = isOpen + return tab + }) + if (!isOpen) { + this.historyIdMapping.delete(tabId) + } + } + } + } + + searchMessages(filter: string): DetailedListItemGroup[] { + let searchResults: DetailedListItemGroup[] = [] + if (this.initialized) { + if (!filter) { + return this.getHistory() + } + + const searchTermLower = filter.toLowerCase() + const tabCollection = this.db.getCollection(TabCollection) + const tabs = tabCollection.find() + const filteredTabs = tabs.filter((tab: Tab) => { + return tab.conversations.some((conversation: Conversation) => { + return conversation.messages.some((message: ChatItem) => { + return message.body?.toLowerCase().includes(searchTermLower) + }) + }) + }) + searchResults = groupTabsByDate(filteredTabs) + } + if (searchResults.length === 0) { + searchResults = [{ children: [{ description: 'No matches found' }] }] + } + return searchResults + } + + /** + * Get messages for specified tabId + * @param tabId The ID of the tab to get messages from + * @param numMessages Optional number of most recent messages to return. If not provided, returns all messages. + */ + getMessages(tabId: string, numMessages?: number) { + if (this.initialized) { + const tabCollection = this.db.getCollection(TabCollection) + const historyId = this.historyIdMapping.get(tabId) + const tabData = historyId ? tabCollection.findOne({ historyId }) : undefined + if (tabData) { + const allMessages = tabData.conversations.flatMap((conversation: Conversation) => conversation.messages) + if (numMessages !== undefined) { + return allMessages.slice(-numMessages) + } + return allMessages + } + } + return [] + } + + getHistory(): DetailedListItemGroup[] { + if (this.initialized) { + const tabCollection = this.db.getCollection(TabCollection) + const tabs = tabCollection.find() + return groupTabsByDate(tabs) + } + return [] + } + + deleteHistory(historyId: string) { + if (this.initialized) { + const tabCollection = this.db.getCollection(TabCollection) + tabCollection.findAndRemove({ historyId }) + const tabId = this.getOpenTabId(historyId) + if (tabId) { + this.historyIdMapping.delete(tabId) + } + } + } + + addMessage(tabId: string, tabType: TabType, conversationId: string, chatItem: ChatItem) { + if (this.initialized) { + const tabCollection = this.db.getCollection(TabCollection) + + let historyId = this.historyIdMapping.get(tabId) + + if (!historyId) { + historyId = crypto.randomUUID() + this.setHistoryIdMapping(tabId, historyId) + } + + const tabData = historyId ? tabCollection.findOne({ historyId }) : undefined + const tabTitle = + (chatItem.type === ('prompt' as ChatItemType) ? chatItem.body : tabData?.title) || 'Amazon Q Chat' + if (tabData) { + tabData.conversations = updateOrCreateConversation(tabData.conversations, conversationId, chatItem) + tabData.updatedAt = new Date() + tabData.title = tabTitle + tabCollection.update(tabData) + } else { + tabCollection.insert({ + historyId, + updatedAt: new Date(), + isOpen: true, + tabType: tabType, + title: tabTitle, + conversations: [{ conversationId, clientType: ClientType.VSCode, messages: [chatItem] }], + }) + } + } + } +} diff --git a/packages/core/src/shared/db/chatDb/util.ts b/packages/core/src/shared/db/chatDb/util.ts new file mode 100644 index 00000000000..a176db4ff40 --- /dev/null +++ b/packages/core/src/shared/db/chatDb/util.ts @@ -0,0 +1,223 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import fs from '../../fs/fs' +import path from 'path' + +import { TabType } from '../../../amazonq/webview/ui/storages/tabsStorage' +import { ChatItem, ChatItemButton, DetailedListItem, DetailedListItemGroup, MynahIconsType } from '@aws/mynah-ui' + +export const TabCollection = 'tabs' + +export const historyPath = path.join('.aws', 'amazonq', 'history') + +export type Tab = { + historyId: string + isOpen: boolean + updatedAt: Date + tabType: TabType + title: string + conversations: Conversation[] +} + +export type Conversation = { + conversationId: string + clientType: ClientType + messages: ChatItem[] +} + +export enum ClientType { + VSCode = 'IDE-VSCode', + JetBrains = 'IDE_JetBrains', + CLI = 'CLI', +} + +/** + * + * This adapter implements the LokiPersistenceAdapter interface for file system operations using web-compatible shared fs utils. + * It provides methods for loading, saving, and deleting databases, as well as ensuring + * the existence of the directory. + * + * Error Handling: + * - All methods use try-catch blocks to to prevent breaking the application + * - In case of errors, the callback functions are used to communicate the error state + * without throwing exceptions. + * + */ +export class FileSystemAdapter implements LokiPersistenceAdapter { + private directory + constructor(directory: string) { + this.directory = directory + } + + async ensureDirectory() { + try { + await fs.exists(this.directory) + } catch { + await fs.mkdir(this.directory) + } + } + + async loadDatabase(dbname: string, callback: (data: string | undefined | Error) => void) { + try { + await this.ensureDirectory() + const filename = path.join(this.directory, dbname) + + const exists = await fs.exists(filename) + if (!exists) { + callback(undefined) + return + } + + const data = await fs.readFileText(filename) + callback(data) + } catch (err: any) { + callback(err) + } + } + + async saveDatabase(dbname: string, dbstring: string, callback: (err: Error | undefined) => void) { + try { + await this.ensureDirectory() + const filename = path.join(this.directory, dbname) + + await fs.writeFile(filename, dbstring, 'utf8') + callback(undefined) + } catch (err: any) { + callback(err) + } + } + + async deleteDatabase(dbname: string, callback: (err: Error | undefined) => void) { + try { + const filename = path.join(this.directory, dbname) + + const exists = await fs.exists(filename) + if (exists) { + await fs.delete(filename) + } + callback(undefined) + } catch (err: any) { + callback(err) + } + } +} + +export function updateOrCreateConversation( + conversations: Conversation[], + conversationId: string, + newMessage: ChatItem +): Conversation[] { + const existingConversation = conversations.find((conv) => conv.conversationId === conversationId) + + if (existingConversation) { + return conversations.map((conv) => + conv.conversationId === conversationId ? { ...conv, messages: [...conv.messages, newMessage] } : conv + ) + } else { + return [ + ...conversations, + { + conversationId, + clientType: ClientType.VSCode, + messages: [newMessage], + }, + ] + } +} + +export function groupTabsByDate(tabs: Tab[]): DetailedListItemGroup[] { + const now = new Date() + const today = new Date(now.setHours(0, 0, 0, 0)) + const yesterday = new Date(today) + yesterday.setDate(yesterday.getDate() - 1) + const lastWeek = new Date(today) + lastWeek.setDate(lastWeek.getDate() - 7) + const lastMonth = new Date(today) + lastMonth.setMonth(lastMonth.getMonth() - 1) + + // Sort tabs by updatedAt in descending order + const sortedTabs = [...tabs] + .map((tab) => ({ ...tab, updatedAt: new Date(tab.updatedAt) })) + .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()) + + // Helper function to convert Tab to DetailedListItem + const tabToDetailedListItem = (tab: Tab): DetailedListItem => ({ + icon: getTabTypeIcon(tab.tabType), + // Show open tabs as bold (in markdown) + description: tab.isOpen ? `**${tab.title}**` : tab.title, + actions: getConversationActions(tab.historyId), + id: tab.historyId, + // Add other properties needed for DetailedListItem + }) + + const tabGroups = [ + { + name: 'Today', + icon: 'calendar' as MynahIconsType, + tabs: sortedTabs.filter((tab) => tab.updatedAt >= today), + }, + { + name: 'Yesterday', + icon: 'calendar' as MynahIconsType, + tabs: sortedTabs.filter((tab) => tab.updatedAt >= yesterday && tab.updatedAt < today), + }, + { + name: 'Last Week', + icon: 'calendar' as MynahIconsType, + tabs: sortedTabs.filter((tab) => tab.updatedAt >= lastWeek && tab.updatedAt < yesterday), + }, + { + name: 'Last Month', + icon: 'calendar' as MynahIconsType, + tabs: sortedTabs.filter((tab) => tab.updatedAt >= lastMonth && tab.updatedAt < lastWeek), + }, + { + name: 'Older', + icon: 'calendar' as MynahIconsType, + tabs: sortedTabs.filter((tab) => tab.updatedAt < lastMonth), + }, + ] + + // Convert to DetailedListItemGroup[] and filter out empty groups + return tabGroups + .filter((group) => group.tabs.length > 0) + .map((group) => ({ + groupName: group.name, + icon: group.icon, + children: group.tabs.map(tabToDetailedListItem), + })) +} + +const getConversationActions = (historyId: string): ChatItemButton[] => [ + { + text: 'Export', + icon: 'trash' as MynahIconsType, + id: historyId, + }, + { + text: 'Delete', + icon: 'external' as MynahIconsType, + id: historyId, + }, +] + +function getTabTypeIcon(tabType: TabType): MynahIconsType { + switch (tabType) { + case 'cwc': + return 'chat' + case 'doc': + return 'file' + case 'review': + return 'bug' + case 'gumby': + return 'transform' + case 'testgen': + return 'check-list' + case 'featuredev': + return 'code-block' + default: + return 'chat' + } +} From 12c0768c224d5f6db4bca0aa93355fce1e8a119b Mon Sep 17 00:00:00 2001 From: zelzhou Date: Wed, 2 Apr 2025 11:23:10 -0700 Subject: [PATCH 03/17] feat(stepfunctions): use readonly mode of workflowStudio to render StateMachine in CDK Applications (#6874) ## Problem As we added support for WorkflowStudio in Toolkit for editing ASL language files, we want to upgrade the render StateMachine Graph in CDK applications experience with the a readonly mode of WorkflowStudio which has better visualization, a code pane for view ASL definition, an export mechanism for exporting ASL definition in JSON/YAML or PNG/SVG. ## Solution Removed the old visualization classes that links to the legacy renderer CDN. From the `renderStateMachineGraphCDK` wizard, upon finding the `template.json` file for a specific stateMachine, we send the file uri to WorkflowStudioEditorProvider to open a readonly WFS to render the StateMachine Graph. --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license. --- package.json | 2 +- .../core/resources/css/stateMachineRender.css | 265 ---------------- .../core/resources/js/graphStateMachine.js | 126 -------- packages/core/src/shared/extensionGlobals.ts | 13 +- packages/core/src/stepFunctions/activation.ts | 31 +- .../abstractAslVisualizationManager.ts | 66 ---- .../visualizeStateMachine/aslVisualization.ts | 298 ------------------ .../aslVisualizationCDK.ts | 35 -- .../aslVisualizationCDKManager.ts | 49 --- .../renderStateMachineGraphCDK.ts | 50 ++- packages/core/src/stepFunctions/utils.ts | 172 ---------- .../workflowStudio/handleMessage.ts | 41 ++- .../src/stepFunctions/workflowStudio/types.ts | 7 + .../workflowStudio/workflowStudioEditor.ts | 31 +- .../workflowStudioEditorProvider.ts | 13 +- .../core/src/test/stepFunctions/utils.test.ts | 167 +--------- .../workflowStudioApiHandler.test.ts | 10 +- packages/core/src/testLint/eslint.test.ts | 2 - ...-bd80ac3c-3d3c-45c3-aa6a-16231a63c43d.json | 4 + 19 files changed, 140 insertions(+), 1242 deletions(-) delete mode 100644 packages/core/resources/css/stateMachineRender.css delete mode 100644 packages/core/resources/js/graphStateMachine.js delete mode 100644 packages/core/src/stepFunctions/commands/visualizeStateMachine/abstractAslVisualizationManager.ts delete mode 100644 packages/core/src/stepFunctions/commands/visualizeStateMachine/aslVisualization.ts delete mode 100644 packages/core/src/stepFunctions/commands/visualizeStateMachine/aslVisualizationCDK.ts delete mode 100644 packages/core/src/stepFunctions/commands/visualizeStateMachine/aslVisualizationCDKManager.ts create mode 100644 packages/toolkit/.changes/next-release/Feature-bd80ac3c-3d3c-45c3-aa6a-16231a63c43d.json diff --git a/package.json b/package.json index d33ff4f4f51..637fc21649a 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "newChange": "echo 'Must specify subproject/workspace with -w packages/' && false", "createRelease": "echo 'Must specify subproject/workspace with -w packages/' && false", "lint": "npm run lint -w packages/ --if-present", - "lintfix": "eslint -c .eslintrc.js --ignore-path .gitignore --ignore-pattern '**/*.json' --ignore-pattern '**/*.gen.ts' --ignore-pattern '**/types/*.d.ts' --ignore-pattern '**/src/testFixtures/**' --ignore-pattern '**/resources/js/graphStateMachine.js' --fix --ext .ts packages plugins", + "lintfix": "eslint -c .eslintrc.js --ignore-path .gitignore --ignore-pattern '**/*.json' --ignore-pattern '**/*.gen.ts' --ignore-pattern '**/types/*.d.ts' --ignore-pattern '**/src/testFixtures/**' --fix --ext .ts packages plugins", "clean": "npm run clean -w packages/ -w plugins/", "reset": "npm run clean && ts-node ./scripts/clean.ts node_modules && npm install", "generateNonCodeFiles": "npm run generateNonCodeFiles -w packages/ --if-present", diff --git a/packages/core/resources/css/stateMachineRender.css b/packages/core/resources/css/stateMachineRender.css deleted file mode 100644 index 2d945ef90b8..00000000000 --- a/packages/core/resources/css/stateMachineRender.css +++ /dev/null @@ -1,265 +0,0 @@ -/*! - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -/* - * Custom theming applied on top of existing styles. - * Adds additional styles for when user has dark/light/high-contrast themes. - * Fixes height issue in vscode webview iframe. - */ - -/* - * General - */ - -:root { - --btnDarkOutlineColor: #c5c5c5; - --btnDarkIconColor: #888; - --btnLightOutlineColor: #b8b8b8; - --btnLightIconColor: #616161; -} - -/* Sizing CSS */ -html, -body { - height: 100%; - margin: 0px; - padding: 0px; - overflow-y: hidden; -} - -#svgcontainer { - flex-grow: 1; - padding: 0px; -} - -/* Theming CSS Below */ -body { - background-color: var(--vscode-editor-background); - display: flex; - align-items: stretch; - justify-content: center; -} - -/* - * Dark Theme Styles - */ - -body.vscode-dark .path { - stroke: #d3d3d3; - background-color: #d3d3d3; -} - -body.vscode-dark path { - stroke: #d3d3d3; -} - -body.vscode-dark .node > .shape { - fill: var(--vscode-editor-background); - stroke: #d3d3d3; -} - -body.vscode-dark .node.anchor > .shape { - fill: #808080; -} - -body.vscode-dark .label { - fill: #ffffff; -} - -body.vscode-dark marker { - fill: #d3d3d3; -} - -/* - * High Contrast Theme Styles - */ - -body.vscode-high-contrast .path { - stroke: var(--vscode-contrastActiveBorder); - background-color: var(--vscode-contrastActiveBorder); -} - -body.vscode-high-contrast path { - stroke: var(--vscode-contrastActiveBorder); -} - -body.vscode-high-contrast .node.anchor > .shape { - fill: var(--vscode-contrastActiveBorder); -} - -body.vscode-high-contrast marker { - fill: var(--vscode-contrastActiveBorder); -} - -.status-info { - display: flex; - position: absolute; - bottom: 10px; - left: 10px; - font-size: 12px; - padding: 10px; - align-items: center; - background-color: var(--vscode-editor-background); - border: 1px solid #c9c9c9; - color: #7b7b7b; -} - -body .status-info.start-error-asl { - display: inline-block; - border: 1px solid #da6363; - color: #da6363; - padding: 10px; - bottom: 50%; - left: 50%; - transform: translate(-50%, 50%); -} - -.status-info.start-error-asl svg, -.status-info.start-error-asl + .graph-buttons-container { - display: none; -} - -.vscode-dark .status-info { - border: 1px solid #6c6c6c; - color: #949494; -} - -.vscode-high-contrast .status-info { - border: 1px solid var(--vscode-contrastActiveBorder); - color: var(--vscode-contrastActiveBorder); -} - -.status-messages span { - display: none; - white-space: nowrap; - line-height: 1; -} - -.status-info svg { - height: 10px; - width: 10px; - margin-right: 10px; -} - -.in-sync-asl .status-messages .previewing-asl-message { - display: inline-block; -} - -.syncing-asl .status-messages .rendering-asl-message { - display: inline-block; -} - -.not-in-sync-asl .status-messages .error-asl-message, -.start-error-asl .status-messages .error-asl-message { - display: inline-block; -} - -.vscode-dark .in-sync-asl svg circle { - fill: #46a52c; -} - -.vscode-dark .syncing-asl svg circle { - fill: #888; -} - -.vscode-dark .not-in-sync-asl svg circle { - fill: #f37f6a; -} - -.in-sync-asl svg circle { - stroke: #004d00; - fill: #a5d099; -} - -.syncing-asl svg circle { - stroke: #333; - fill: #d8d8d8; -} - -.not-in-sync-asl svg circle { - stroke: #660000; - fill: #f2ada0; -} - -a { - cursor: pointer; -} - -.graph-buttons-container { - position: absolute; - top: 10px; - right: 10px; - display: flex; - flex-direction: column; - z-index: 1000; -} - -.graph-buttons-container button { - background-color: var(--vscode-editor-background); - border: 1px solid var(--btnLightOutlineColor); - border-radius: 2px; - display: flex; - align-items: center; - justify-content: center; - text-decoration: none; - cursor: pointer; - width: 38px; - height: 30px; - margin-bottom: 10px; -} - -#center svg circle:nth-child(2) { - fill: var(--btnLightIconColor); -} - -.vscode-dark #center svg circle:nth-child(2) { - fill: var(--btnLightIconColor); -} - -.graph-buttons-container button svg { - width: 14px; - height: 14px; - stroke-width: 2px; -} - -.graph-buttons-container button:active { - opacity: 0.7; -} - -.vscode-high-contrast .graph-buttons-container button { - border: 1px solid white; -} - -.vscode-dark .graph-buttons-container button { - border: 1px solid var(--btnDarkOutlineColor); -} - -.graph-buttons-container button line, -.graph-buttons-container button circle { - stroke: var(--btnLightIconColor); - fill: transparent; -} - -.graph-buttons-container button circle:nth-child(2) { - fill: var(--btnLightIconColor); -} - -.vscode-high-contrast .graph-buttons-container button line, -.vscode-high-contrast .graph-buttons-container button circle { - stroke: white; -} - -.vscode-dark .graph-buttons-container button line, -.vscode-dark .graph-buttons-container button circle { - stroke: var(--btnDarkIconColor); -} - -.vscode-high-contrast .graph-buttons-container button circle:nth-child(2) { - fill: var(--btnDarkIconColor); -} - -.graph-buttons-container button:focus { - outline: none; -} diff --git a/packages/core/resources/js/graphStateMachine.js b/packages/core/resources/js/graphStateMachine.js deleted file mode 100644 index 9ccff2145fa..00000000000 --- a/packages/core/resources/js/graphStateMachine.js +++ /dev/null @@ -1,126 +0,0 @@ -/*! - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -const vscode = acquireVsCodeApi() - -let containerId = '#svgcontainer' -let graph - -function renderStateMachine(data) { - let options = { - width: window.innerWidth, - height: window.innerHeight, - resizeHeight: false, - } - graph = new sfn.StateMachineGraph(JSON.parse(data), containerId, options) - graph.render() -} - -const centerBtn = document.getElementById('center') -const zoominBtn = document.getElementById('zoomin') -const zoomoutBtn = document.getElementById('zoomout') -let lastStateMachineData - -function updateGraph(message) { - let options = { - width: window.innerWidth, - height: window.innerHeight, - resizeHeight: false, - } - - statusInfoContainer.classList.remove('in-sync-asl', 'not-in-sync-asl', 'start-error-asl') - statusInfoContainer.classList.add('syncing-asl') - - if (!message.isValid) { - statusInfoContainer.classList.remove('syncing-asl', 'in-sync-asl', 'start-error-asl') - - if (hasRenderedOnce) { - statusInfoContainer.classList.add('not-in-sync-asl') - } else { - statusInfoContainer.classList.add('start-error-asl') - } - - return - } - - try { - renderStateMachine(message.stateMachineData) - - vscode.postMessage({ - command: 'updateResult', - text: 'Successfully updated state machine graph.', - stateMachineData: message.stateMachineData, - }) - statusInfoContainer.classList.remove('syncing-asl', 'not-in-sync-asl', 'start-error-asl') - statusInfoContainer.classList.add('in-sync-asl') - hasRenderedOnce = true - lastStateMachineData = message.stateMachineData - } catch (err) { - console.log('Error parsing state machine definition.') - console.log(err) - - vscode.postMessage({ - command: 'updateResult', - text: 'Error parsing state machine definition.', - error: err.toString(), - stateMachineData: message.stateMachineData, - }) - - statusInfoContainer.classList.remove('syncing-asl', 'in-sync-asl', 'start-error-asl') - - if (hasRenderedOnce) { - statusInfoContainer.classList.add('not-in-sync-asl') - } else { - statusInfoContainer.classList.add('start-error-asl') - } - } -} - -const statusInfoContainer = document.querySelector('.status-info') -const previewButton = document.querySelector('.previewing-asl-message a') -let hasRenderedOnce = false - -if (previewButton) { - previewButton.addEventListener('click', () => { - vscode.postMessage({ command: 'viewDocument' }) - }) -} - -centerBtn.addEventListener('click', () => { - if (lastStateMachineData) { - renderStateMachine(lastStateMachineData) - } -}) - -zoominBtn.addEventListener('click', () => { - if (graph) { - graph.zoomIn() - } -}) - -zoomoutBtn.addEventListener('click', () => { - if (graph) { - graph.zoomOut() - } -}) - -// Message passing from extension to webview. -// Capture state machine definition -window.addEventListener('message', (event) => { - // event.data is object passed in from postMessage from vscode - const message = event.data - switch (message.command) { - case 'update': { - updateGraph(message) - break - } - } -}) - -// Let vscode know that the webview is finished rendering -vscode.postMessage({ - command: 'webviewRendered', - text: 'Webivew has finished rendering and is visible', -}) diff --git a/packages/core/src/shared/extensionGlobals.ts b/packages/core/src/shared/extensionGlobals.ts index a8650b87ad8..e0eca894d7e 100644 --- a/packages/core/src/shared/extensionGlobals.ts +++ b/packages/core/src/shared/extensionGlobals.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ExtensionContext, OutputChannel, Uri } from 'vscode' +import { ExtensionContext, OutputChannel } from 'vscode' import { LoginManager } from '../auth/deprecated/loginManager' import { AwsResourceManager } from '../dynamicResources/awsResourceManager' import { AWSClientBuilder } from './awsClientBuilder' @@ -153,7 +153,6 @@ export function initialize(context: ExtensionContext, isWeb: boolean = false): T // eslint-disable-next-line aws-toolkits/no-banned-usages globalState: new GlobalState(context.globalState), manifestPaths: {} as ToolkitGlobals['manifestPaths'], - visualizationResourcePaths: {} as ToolkitGlobals['visualizationResourcePaths'], isWeb, }) void setContext('aws.isWebExtHost', isWeb) @@ -222,16 +221,6 @@ export interface ToolkitGlobals { */ readonly clock: Clock - visualizationResourcePaths: { - localWebviewScriptsPath: Uri - webviewBodyScript: Uri - visualizationLibraryCachePath: Uri - visualizationLibraryScript: Uri - visualizationLibraryCSS: Uri - stateMachineCustomThemePath: Uri - stateMachineCustomThemeCSS: Uri - } - readonly manifestPaths: { endpoints: string lambdaSampleRequests: string diff --git a/packages/core/src/stepFunctions/activation.ts b/packages/core/src/stepFunctions/activation.ts index 7d8107b8119..4898fc36b54 100644 --- a/packages/core/src/stepFunctions/activation.ts +++ b/packages/core/src/stepFunctions/activation.ts @@ -8,7 +8,6 @@ import globals from '../shared/extensionGlobals' import * as nls from 'vscode-nls' const localize = nls.loadMessageBundle() -import { join } from 'path' import * as vscode from 'vscode' import { AwsContext } from '../shared/awsContext' import { createStateMachineFromTemplate } from './commands/createStateMachineFromTemplate' @@ -16,7 +15,6 @@ import { publishStateMachine } from './commands/publishStateMachine' import { Commands } from '../shared/vscode/commands2' import { ASL_FORMATS, YAML_ASL, JSON_ASL } from './constants/aslFormats' -import { AslVisualizationCDKManager } from './commands/visualizeStateMachine/aslVisualizationCDKManager' import { renderCdkStateMachineGraph } from './commands/visualizeStateMachine/renderStateMachineGraphCDK' import { ToolkitError } from '../shared/errors' import { telemetry } from '../shared/telemetry/telemetry' @@ -34,8 +32,6 @@ export async function activate( awsContext: AwsContext, outputChannel: vscode.OutputChannel ): Promise { - globals.visualizationResourcePaths = initalizeWebviewPaths(extensionContext) - await registerStepFunctionCommands(extensionContext, awsContext, outputChannel) initializeCodeLens(extensionContext) @@ -88,11 +84,9 @@ async function registerStepFunctionCommands( awsContext: AwsContext, outputChannel: vscode.OutputChannel ): Promise { - const cdkVisualizationManager = new AslVisualizationCDKManager(extensionContext) - extensionContext.subscriptions.push( previewStateMachineCommand.register(), - renderCdkStateMachineGraph.register(cdkVisualizationManager), + renderCdkStateMachineGraph.register(), Commands.register('aws.stepfunctions.createStateMachineFromTemplate', async () => { try { await createStateMachineFromTemplate(extensionContext) @@ -107,29 +101,6 @@ async function registerStepFunctionCommands( ) } -export function initalizeWebviewPaths( - context: vscode.ExtensionContext -): (typeof globals)['visualizationResourcePaths'] { - // Location for script in body of webview that handles input from user - // and calls the code to render state machine graph - - // Locations for script and css that render the state machine - const visualizationLibraryCache = join(context.globalStorageUri.fsPath, 'visualization') - - return { - localWebviewScriptsPath: vscode.Uri.file(context.asAbsolutePath(join('resources', 'js'))), - webviewBodyScript: vscode.Uri.file(context.asAbsolutePath(join('resources', 'js', 'graphStateMachine.js'))), - visualizationLibraryCachePath: vscode.Uri.file(visualizationLibraryCache), - visualizationLibraryScript: vscode.Uri.file(join(visualizationLibraryCache, 'graph.js')), - visualizationLibraryCSS: vscode.Uri.file(join(visualizationLibraryCache, 'graph.css')), - // Locations for an additional stylesheet to add Light/Dark/High-Contrast theme support - stateMachineCustomThemePath: vscode.Uri.file(context.asAbsolutePath(join('resources', 'css'))), - stateMachineCustomThemeCSS: vscode.Uri.file( - context.asAbsolutePath(join('resources', 'css', 'stateMachineRender.css')) - ), - } -} - async function startLspServer(extensionContext: vscode.ExtensionContext) { const perflog = new PerfLog('stepFunctions: start LSP client/server') await ASLLanguageClient.create(extensionContext) diff --git a/packages/core/src/stepFunctions/commands/visualizeStateMachine/abstractAslVisualizationManager.ts b/packages/core/src/stepFunctions/commands/visualizeStateMachine/abstractAslVisualizationManager.ts deleted file mode 100644 index 1c6eb55c26c..00000000000 --- a/packages/core/src/stepFunctions/commands/visualizeStateMachine/abstractAslVisualizationManager.ts +++ /dev/null @@ -1,66 +0,0 @@ -/*! - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as vscode from 'vscode' -import * as nls from 'vscode-nls' -import { StateMachineGraphCache } from '../../utils' - -import { Logger } from '../../../shared/logger/logger' -import { AslVisualization } from './aslVisualization' - -const localize = nls.loadMessageBundle() - -export abstract class AbstractAslVisualizationManager { - protected abstract readonly name: string - protected readonly managedVisualizations = new Map() - protected readonly cache = new StateMachineGraphCache() - - public constructor(private readonly extensionContext: vscode.ExtensionContext) {} - - public abstract visualizeStateMachine(uri: vscode.Uri): Promise - - protected pushToExtensionContextSubscriptions(visualizationDisposable: vscode.Disposable): void { - this.extensionContext.subscriptions.push(visualizationDisposable) - } - - protected handleErr(err: Error, logger: Logger): void { - void vscode.window.showInformationMessage( - localize( - 'AWS.stepfunctions.visualisation.errors.rendering', - 'There was an error rendering State Machine Graph, check logs for details.' - ) - ) - - logger.debug(`${this.name}: Unable to setup webview panel.`) - logger.error(`${this.name}: unexpected exception: %s`, err) - } - - public getManagedVisualizations(): Map { - return this.managedVisualizations - } - - protected handleNewVisualization(key: string, visualization: T): void { - this.managedVisualizations.set(key, visualization) - - const visualizationDisposable = visualization.onVisualizationDisposeEvent(() => { - this.managedVisualizations.delete(key) - }) - this.pushToExtensionContextSubscriptions(visualizationDisposable) - } - - protected getExistingVisualization(key: string): T | undefined { - return this.managedVisualizations.get(key) - } - - protected async updateCache(logger: Logger): Promise { - try { - await this.cache.updateCache() - } catch (err) { - // So we can't update the cache, but can we use an existing on disk version. - logger.warn('Updating State Machine Graph Visualisation assets failed, checking for fallback local cache.') - await this.cache.confirmCacheExists() - } - } -} diff --git a/packages/core/src/stepFunctions/commands/visualizeStateMachine/aslVisualization.ts b/packages/core/src/stepFunctions/commands/visualizeStateMachine/aslVisualization.ts deleted file mode 100644 index 69f145c3eaf..00000000000 --- a/packages/core/src/stepFunctions/commands/visualizeStateMachine/aslVisualization.ts +++ /dev/null @@ -1,298 +0,0 @@ -/*! - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as nls from 'vscode-nls' -const localize = nls.loadMessageBundle() -import { debounce } from 'lodash' -import * as path from 'path' -import * as vscode from 'vscode' -import { getLogger, Logger } from '../../../shared/logger/logger' -import { isDocumentValid } from '../../utils' -import * as yaml from 'js-yaml' - -import { YAML_FORMATS } from '../../constants/aslFormats' -import globals from '../../../shared/extensionGlobals' - -export interface MessageObject { - command: string - text: string - error?: string - stateMachineData: string -} - -export class AslVisualization { - public readonly documentUri: vscode.Uri - public readonly webviewPanel: vscode.WebviewPanel - protected readonly disposables: vscode.Disposable[] = [] - protected isPanelDisposed = false - private readonly onVisualizationDisposeEmitter = new vscode.EventEmitter() - - public constructor(textDocument: vscode.TextDocument) { - this.documentUri = textDocument.uri - this.webviewPanel = this.setupWebviewPanel(textDocument) - } - - public get onVisualizationDisposeEvent(): vscode.Event { - return this.onVisualizationDisposeEmitter.event - } - - public getPanel(): vscode.WebviewPanel | undefined { - if (!this.isPanelDisposed) { - return this.webviewPanel - } - } - - public getWebview(): vscode.Webview | undefined { - if (!this.isPanelDisposed) { - return this.webviewPanel?.webview - } - } - - public showPanel(): void { - this.getPanel()?.reveal() - } - - public async sendUpdateMessage(updatedTextDocument: vscode.TextDocument) { - const logger: Logger = getLogger() - const isYaml = YAML_FORMATS.includes(updatedTextDocument.languageId) - const text = this.getText(updatedTextDocument) - let stateMachineData = text - - if (isYaml) { - let json: any - - try { - json = yaml.load(text, { schema: yaml.JSON_SCHEMA }) - } catch (e: any) { - const msg = e instanceof yaml.YAMLException ? e.message : 'unknown error' - getLogger().error(`Error parsing state machine YAML: ${msg}`) - } - - stateMachineData = JSON.stringify(json) - } - - const isValid = await isDocumentValid(stateMachineData, updatedTextDocument) - - const webview = this.getWebview() - if (this.isPanelDisposed || !webview) { - return - } - - logger.debug('Sending update message to webview.') - - await webview.postMessage({ - command: 'update', - stateMachineData, - isValid, - }) - } - - protected getText(textDocument: vscode.TextDocument): string { - return textDocument.getText() - } - - private setupWebviewPanel(textDocument: vscode.TextDocument): vscode.WebviewPanel { - const documentUri = textDocument.uri - const logger: Logger = getLogger() - - // Create and show panel - const panel = this.createVisualizationWebviewPanel(documentUri) - - // Set the initial html for the webpage - panel.webview.html = this.getWebviewContent( - panel.webview.asWebviewUri(globals.visualizationResourcePaths.webviewBodyScript), - panel.webview.asWebviewUri(globals.visualizationResourcePaths.visualizationLibraryScript), - panel.webview.asWebviewUri(globals.visualizationResourcePaths.visualizationLibraryCSS), - panel.webview.asWebviewUri(globals.visualizationResourcePaths.stateMachineCustomThemeCSS), - panel.webview.cspSource, - { - inSync: localize( - 'AWS.stepFunctions.graph.status.inSync', - 'Previewing ASL document. View' - ), - notInSync: localize('AWS.stepFunctions.graph.status.notInSync', 'Errors detected. Cannot preview.'), - syncing: localize('AWS.stepFunctions.graph.status.syncing', 'Rendering ASL graph...'), - } - ) - - // Add listener function to update the graph on document save - this.disposables.push( - vscode.workspace.onDidSaveTextDocument(async (savedTextDocument) => { - if (savedTextDocument && savedTextDocument.uri.path === documentUri.path) { - await this.sendUpdateMessage(savedTextDocument) - } - }) - ) - - // If documentUri being tracked is no longer found (due to file closure or rename), close the panel. - this.disposables.push( - vscode.workspace.onDidCloseTextDocument((documentWillSaveEvent) => { - if (!this.trackedDocumentDoesExist(documentUri) && !this.isPanelDisposed) { - panel.dispose() - void vscode.window.showInformationMessage( - localize( - 'AWS.stepfunctions.visualisation.errors.rename', - 'State machine visualization closed due to file renaming or closure.' - ) - ) - } - }) - ) - - const debouncedUpdate = debounce(this.sendUpdateMessage.bind(this), 500) - - this.disposables.push( - vscode.workspace.onDidChangeTextDocument(async (textDocumentEvent) => { - if (textDocumentEvent.document.uri.path === documentUri.path) { - await debouncedUpdate(textDocumentEvent.document) - } - }) - ) - - // Handle messages from the webview - this.disposables.push( - panel.webview.onDidReceiveMessage(async (message: MessageObject) => { - switch (message.command) { - case 'updateResult': - logger.debug(message.text) - if (message.error) { - logger.error(message.error) - } - break - case 'webviewRendered': { - // Webview has finished rendering, so now we can give it our - // initial state machine definition. - await this.sendUpdateMessage(textDocument) - break - } - - case 'viewDocument': - try { - const document = await vscode.workspace.openTextDocument(documentUri) - void vscode.window.showTextDocument(document, vscode.ViewColumn.One) - } catch (e) { - logger.error(e as Error) - } - break - } - }) - ) - - // When the panel is closed, dispose of any disposables/remove subscriptions - const disposePanel = () => { - if (this.isPanelDisposed) { - return - } - this.isPanelDisposed = true - debouncedUpdate.cancel() - this.onVisualizationDisposeEmitter.fire() - for (const disposable of this.disposables) { - disposable.dispose() - } - this.onVisualizationDisposeEmitter.dispose() - } - - this.disposables.push( - panel.onDidDispose(() => { - disposePanel() - }) - ) - - return panel - } - - private createVisualizationWebviewPanel(documentUri: vscode.Uri): vscode.WebviewPanel { - return vscode.window.createWebviewPanel( - 'stateMachineVisualization', - localize('AWS.stepFunctions.graph.titlePrefix', 'Graph: {0}', path.basename(documentUri.fsPath)), - { - preserveFocus: true, - viewColumn: vscode.ViewColumn.Beside, - }, - { - enableScripts: true, - localResourceRoots: [ - globals.visualizationResourcePaths.localWebviewScriptsPath, - globals.visualizationResourcePaths.visualizationLibraryCachePath, - globals.visualizationResourcePaths.stateMachineCustomThemePath, - ], - retainContextWhenHidden: true, - } - ) - } - - private getWebviewContent( - webviewBodyScript: vscode.Uri, - graphStateMachineLibrary: vscode.Uri, - vsCodeCustomStyling: vscode.Uri, - graphStateMachineDefaultStyles: vscode.Uri, - cspSource: string, - statusTexts: { - syncing: string - notInSync: string - inSync: string - } - ): string { - return ` - - - - - - - - - - - -
- -
-
- - - -
- ${statusTexts.inSync} - ${statusTexts.syncing} - ${statusTexts.notInSync} -
-
-
- - - -
- - - - ` - } - - private trackedDocumentDoesExist(trackedDocumentURI: vscode.Uri): boolean { - const document = vscode.workspace.textDocuments.find((doc) => doc.fileName === trackedDocumentURI.fsPath) - - return document !== undefined - } -} diff --git a/packages/core/src/stepFunctions/commands/visualizeStateMachine/aslVisualizationCDK.ts b/packages/core/src/stepFunctions/commands/visualizeStateMachine/aslVisualizationCDK.ts deleted file mode 100644 index b62cfab72e5..00000000000 --- a/packages/core/src/stepFunctions/commands/visualizeStateMachine/aslVisualizationCDK.ts +++ /dev/null @@ -1,35 +0,0 @@ -/*! - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as vscode from 'vscode' -import { AslVisualization } from './aslVisualization' -import { - getStateMachineDefinitionFromCfnTemplate, - toUnescapedAslJsonString, -} from './getStateMachineDefinitionFromCfnTemplate' - -export class AslVisualizationCDK extends AslVisualization { - public constructor( - textDocument: vscode.TextDocument, - public readonly templatePath: string, - public readonly stateMachineName: string - ) { - super(textDocument) - this.templatePath = templatePath - this.stateMachineName = stateMachineName - } - - protected override getText(textDocument: vscode.TextDocument): string { - this.updateWebviewTitle() - const definitionString = getStateMachineDefinitionFromCfnTemplate(this.stateMachineName, this.templatePath) - return toUnescapedAslJsonString(definitionString ? definitionString : '') - } - - protected updateWebviewTitle(): void { - if (this.getPanel()) { - this.getPanel()!.title = this.stateMachineName - } - } -} diff --git a/packages/core/src/stepFunctions/commands/visualizeStateMachine/aslVisualizationCDKManager.ts b/packages/core/src/stepFunctions/commands/visualizeStateMachine/aslVisualizationCDKManager.ts deleted file mode 100644 index ce8a05c0dfd..00000000000 --- a/packages/core/src/stepFunctions/commands/visualizeStateMachine/aslVisualizationCDKManager.ts +++ /dev/null @@ -1,49 +0,0 @@ -/*! - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as vscode from 'vscode' - -import { AslVisualizationCDK } from './aslVisualizationCDK' -import { AbstractAslVisualizationManager } from './abstractAslVisualizationManager' -import { getLogger } from '../../../shared/logger/logger' - -export class AslVisualizationCDKManager extends AbstractAslVisualizationManager { - protected readonly name: string = 'AslVisualizationCDKManager' - - public constructor(extensionContext: vscode.ExtensionContext) { - super(extensionContext) - } - - public async visualizeStateMachine(uri: vscode.Uri): Promise { - const logger = getLogger() - const existingVisualization = this.getExistingVisualization(this.getKey(uri)) - - if (existingVisualization) { - existingVisualization.showPanel() - - return existingVisualization.getPanel() - } - - const [appName, resourceName] = uri.fragment.split('/') - const cdkOutPath = vscode.Uri.joinPath(uri, '..') - const templateUri = vscode.Uri.joinPath(cdkOutPath, `${appName}.template.json`) - - try { - await this.cache.updateCache() - - const textDocument = await vscode.workspace.openTextDocument(templateUri.with({ fragment: '' })) - const newVisualization = new AslVisualizationCDK(textDocument, templateUri.fsPath, resourceName) - this.handleNewVisualization(this.getKey(uri), newVisualization) - - return newVisualization.getPanel() - } catch (err) { - this.handleErr(err as Error, logger) - } - } - - private getKey(uri: vscode.Uri): string { - return `${uri.path}#${uri.fragment}` - } -} diff --git a/packages/core/src/stepFunctions/commands/visualizeStateMachine/renderStateMachineGraphCDK.ts b/packages/core/src/stepFunctions/commands/visualizeStateMachine/renderStateMachineGraphCDK.ts index 5a03e949ca7..0a473e80201 100644 --- a/packages/core/src/stepFunctions/commands/visualizeStateMachine/renderStateMachineGraphCDK.ts +++ b/packages/core/src/stepFunctions/commands/visualizeStateMachine/renderStateMachineGraphCDK.ts @@ -4,12 +4,19 @@ */ import * as vscode from 'vscode' +import * as nls from 'vscode-nls' -import { AslVisualizationCDKManager } from './aslVisualizationCDKManager' -import { PreviewStateMachineCDKWizard } from '../../wizards/previewStateMachineCDKWizard' -import { Commands } from '../../../shared/vscode/commands2' +import { getLogger } from '../../../shared/logger/logger' +import { telemetry } from '../../../shared/telemetry/telemetry' import { isTreeNode } from '../../../shared/treeview/resourceTreeDataProvider' import { unboxTreeNode } from '../../../shared/treeview/utils' +import { Commands } from '../../../shared/vscode/commands2' +import { PreviewStateMachineCDKWizard } from '../../wizards/previewStateMachineCDKWizard' +import { WorkflowMode } from '../../workflowStudio/types' +import { WorkflowStudioEditorProvider } from '../../workflowStudio/workflowStudioEditorProvider' +import { getStateMachineDefinitionFromCfnTemplate } from './getStateMachineDefinitionFromCfnTemplate' + +const localize = nls.loadMessageBundle() function isLocationResource(obj: unknown): obj is { location: vscode.Uri } { return !!obj && typeof obj === 'object' && (obj as any).location instanceof vscode.Uri @@ -23,14 +30,47 @@ function isLocationResource(obj: unknown): obj is { location: vscode.Uri } { */ export const renderCdkStateMachineGraph = Commands.declare( 'aws.cdk.renderStateMachineGraph', - (manager: AslVisualizationCDKManager) => async (input?: unknown) => { + () => async (input?: unknown) => { const resource = isTreeNode(input) ? unboxTreeNode(input, isLocationResource) : undefined const resourceUri = resource?.location ?? (await new PreviewStateMachineCDKWizard().run())?.resource.location if (!resourceUri) { return } + const logger = getLogger('stepfunctions') + try { + telemetry.ui_click.emit({ + elementId: 'stepfunctions_renderCDKStateMachineGraph', + }) + const [appName, resourceName] = resourceUri.fragment.split('/') + const cdkOutPath = vscode.Uri.joinPath(resourceUri, '..') + const templateUri = vscode.Uri.joinPath(cdkOutPath, `${appName}.template.json`) + const definitionString = getStateMachineDefinitionFromCfnTemplate(resourceName, templateUri.fsPath) + + if (definitionString) { + // Append stateMachineName and Readonly WorkflowMode to templateUri + // to instruct WorkflowStudioEditorProvider to open in Readonly mode and get ASL definition from CloudFormation template + const query = `statemachineName=${encodeURIComponent(resourceName)}&workflowMode=${encodeURIComponent(WorkflowMode.Readonly)}` + const wfsUriWithTemplateInfo = templateUri.with({ query }) + await WorkflowStudioEditorProvider.openWithWorkflowStudio(wfsUriWithTemplateInfo) + } else { + void vscode.window.showErrorMessage( + localize( + 'AWS.stepfunctions.visualisation.errors.rendering', + 'There was an error rendering State Machine Graph, check logs for details.' + ) + ) + logger.error('Unable to extract state machine definition string from template.json file.') + } + } catch (err) { + void vscode.window.showErrorMessage( + localize( + 'AWS.stepfunctions.visualisation.errors.rendering', + 'There was an error rendering State Machine Graph, check logs for details.' + ) + ) - await manager.visualizeStateMachine(resourceUri) + logger.error(`Unexpected exception: %s`, err) + } } ) diff --git a/packages/core/src/stepFunctions/utils.ts b/packages/core/src/stepFunctions/utils.ts index 6085923d089..fedea23acc5 100644 --- a/packages/core/src/stepFunctions/utils.ts +++ b/packages/core/src/stepFunctions/utils.ts @@ -9,190 +9,18 @@ import { StepFunctions } from 'aws-sdk' import * as yaml from 'js-yaml' import * as vscode from 'vscode' import { StepFunctionsClient } from '../shared/clients/stepFunctionsClient' -import { fileExists } from '../shared/filesystemUtilities' -import { getLogger, Logger } from '../shared/logger/logger' import { DiagnosticSeverity, DocumentLanguageSettings, getLanguageService, TextDocument as ASLTextDocument, } from 'amazon-states-language-service' -import { HttpResourceFetcher } from '../shared/resourcefetcher/httpResourceFetcher' -import globals from '../shared/extensionGlobals' import { fromExtensionManifest } from '../shared/settings' -import { fs } from '../shared/fs/fs' import { IamRole } from '../shared/clients/iam' const documentSettings: DocumentLanguageSettings = { comments: 'error', trailingCommas: 'error' } const languageService = getLanguageService({}) -const visualizationScriptUrl = 'https://d3p8cpu0nuk1gf.cloudfront.net/sfn-0.1.8.js' -const visualizationCssUrl = 'https://d3p8cpu0nuk1gf.cloudfront.net/graph-0.1.8.css' - -const scriptsLastDownloadedUrl = 'SCRIPT_LAST_DOWNLOADED_URL' -const cssLastDownloadedUrl = 'CSS_LAST_DOWNLOADED_URL' - -export interface UpdateCachedScriptOptions { - lastDownloadedURLKey: 'SCRIPT_LAST_DOWNLOADED_URL' | 'CSS_LAST_DOWNLOADED_URL' - currentURL: string - filePath: string -} - -export interface StateMachineGraphCacheOptions { - cssFilePath?: string - jsFilePath?: string - dirPath?: string - scriptUrl?: string - cssUrl?: string - writeFile?(path: string, data: string, encoding: string): Promise - makeDir?(path: string): Promise - getFileData?(url: string): Promise - fileExists?(path: string): Promise -} - -export class StateMachineGraphCache { - protected makeDir: (path: string) => Promise - protected writeFile: (path: string, data: string, encoding: string) => Promise - protected getFileData: (url: string) => Promise - protected fileExists: (path: string) => Promise - protected logger: Logger - protected cssFilePath: string - protected jsFilePath: string - protected dirPath: string - - public constructor(options: StateMachineGraphCacheOptions = {}) { - // eslint-disable-next-line @typescript-eslint/unbound-method - const { makeDir, writeFile: writeFileCustom, getFileData, fileExists: fileExistsCustom } = options - - this.makeDir = - makeDir ?? - (async (path: string) => { - await fs.mkdir(path) - }) - this.writeFile = - writeFileCustom ?? - (async (path: string, data: string, _encoding: string) => { - await fs.writeFile(path, data) - }) - this.logger = getLogger() - this.getFileData = getFileData ?? httpsGetRequestWrapper - this.cssFilePath = options.cssFilePath ?? globals.visualizationResourcePaths.visualizationLibraryCSS.fsPath - this.jsFilePath = options.jsFilePath ?? globals.visualizationResourcePaths.visualizationLibraryScript.fsPath - this.dirPath = options.dirPath ?? globals.visualizationResourcePaths.visualizationLibraryCachePath.fsPath - this.fileExists = fileExistsCustom ?? fileExists - } - - public async updateCache(): Promise { - const scriptUpdate = this.updateCachedFile({ - lastDownloadedURLKey: scriptsLastDownloadedUrl, - currentURL: visualizationScriptUrl, - filePath: this.jsFilePath, - }).catch((error) => { - this.logger.error('Failed to update State Machine Graph script assets') - this.logger.error(error as Error) - - throw error - }) - - const cssUpdate = this.updateCachedFile({ - lastDownloadedURLKey: cssLastDownloadedUrl, - currentURL: visualizationCssUrl, - filePath: this.cssFilePath, - }).catch((error) => { - this.logger.error('Failed to update State Machine Graph css assets') - this.logger.error(error as Error) - - throw error - }) - - await Promise.all([scriptUpdate, cssUpdate]) - } - - public async updateCachedFile(options: UpdateCachedScriptOptions) { - const downloadedUrl = globals.globalState.tryGet(options.lastDownloadedURLKey, String) - const cachedFileExists = await this.fileExists(options.filePath) - - // if current url is different than url that was previously used to download the assets - // or if the file assets do not exist - // download and cache the assets - if (downloadedUrl !== options.currentURL || !cachedFileExists) { - const response = await this.getFileData(options.currentURL) - await this.writeToLocalStorage(options.filePath, response) - - // save the url of the downloaded and cached assets - await globals.globalState.update(options.lastDownloadedURLKey, options.currentURL) - } - } - - // Coordinates check for multiple cached files. - public async confirmCacheExists(): Promise { - const cssExists = await this.fileExists(this.cssFilePath) - const jsExists = await this.fileExists(this.jsFilePath) - - if (cssExists && jsExists) { - return true - } - - if (!cssExists) { - // Help users setup on disconnected VSCode instances. - this.logger.error( - `Failed to locate cached State Machine Graph css assets. Expected to find: "${visualizationCssUrl}" at "${this.cssFilePath}"` - ) - } - if (!jsExists) { - // Help users setup on disconnected VSCode instances. - this.logger.error( - `Failed to locate cached State Machine Graph js assets. Expected to find: "${visualizationScriptUrl}" at "${this.jsFilePath}"` - ) - } - throw new Error('Failed to located cached State Machine Graph assets') - } - - protected async writeToLocalStorage(destinationPath: string, data: string): Promise { - const storageFolder = this.dirPath - - try { - this.logger.debug('stepFunctions: creating directory: %O', storageFolder) - await this.makeDir(storageFolder) - } catch (err) { - const error = err as Error & { code?: string } - this.logger.verbose(error) - // EEXIST failure is non-fatal. This function is called as part of - // a Promise.all() group of tasks wanting to create the same directory. - if (error.code && error.code !== 'EEXIST') { - throw err - } - } - - try { - await this.writeFile(destinationPath, data, 'utf8') - } catch (err) { - /* - * Was able to download the required files, - * but there was an error trying to write them to this extensions globalStorage location. - */ - this.logger.error(err as Error) - throw err - } - } -} - -async function httpsGetRequestWrapper(url: string): Promise { - const logger = getLogger() - logger.verbose('Step Functions is getting content...') - - const fetcher = await new HttpResourceFetcher(url, { showUrl: true }).get() - const val = await fetcher?.text() - - if (!val) { - const message = 'Step Functions was unable to get content.' - logger.verbose(message) - throw new Error(message) - } - - return val -} - export async function* listStateMachines( client: StepFunctionsClient ): AsyncIterableIterator { diff --git a/packages/core/src/stepFunctions/workflowStudio/handleMessage.ts b/packages/core/src/stepFunctions/workflowStudio/handleMessage.ts index de76115623c..13477db19e2 100644 --- a/packages/core/src/stepFunctions/workflowStudio/handleMessage.ts +++ b/packages/core/src/stepFunctions/workflowStudio/handleMessage.ts @@ -15,6 +15,7 @@ import { SyncFileRequestMessage, ApiCallRequestMessage, UnsupportedMessage, + WorkflowMode, } from './types' import { submitFeedback } from '../../feedback/vue/submitFeedback' import { placeholder } from '../../shared/vscode/commands2' @@ -26,6 +27,10 @@ import { WorkflowStudioApiHandler } from './workflowStudioApiHandler' import globals from '../../shared/extensionGlobals' import { getLogger } from '../../shared/logger/logger' import { publishStateMachine } from '../commands/publishStateMachine' +import { + getStateMachineDefinitionFromCfnTemplate, + toUnescapedAslJsonString, +} from '../commands/visualizeStateMachine/getStateMachineDefinitionFromCfnTemplate' const localize = nls.loadMessageBundle() @@ -37,29 +42,29 @@ const localize = nls.loadMessageBundle() */ export async function handleMessage(message: Message, context: WebviewContext) { const { command, messageType } = message - + const isReadonlyMode = context.mode === WorkflowMode.Readonly if (messageType === MessageType.REQUEST) { switch (command) { case Command.INIT: void initMessageHandler(context) break case Command.SAVE_FILE: - void saveFileMessageHandler(message as SaveFileRequestMessage, context) + !isReadonlyMode && void saveFileMessageHandler(message as SaveFileRequestMessage, context) break case Command.SAVE_FILE_AND_DEPLOY: - void saveFileAndDeployMessageHandler(message as SaveFileRequestMessage, context) + !isReadonlyMode && void saveFileAndDeployMessageHandler(message as SaveFileRequestMessage, context) break case Command.AUTO_SYNC_FILE: - void autoSyncFileMessageHandler(message as SyncFileRequestMessage, context) + !isReadonlyMode && void autoSyncFileMessageHandler(message as SyncFileRequestMessage, context) break case Command.CLOSE_WFS: void closeCustomEditorMessageHandler(context) break case Command.OPEN_FEEDBACK: - void submitFeedback(placeholder, 'Workflow Studio') + !isReadonlyMode && void submitFeedback(placeholder, 'Workflow Studio') break case Command.API_CALL: - apiCallMessageHandler(message as ApiCallRequestMessage, context) + !isReadonlyMode && apiCallMessageHandler(message as ApiCallRequestMessage, context) break default: void handleUnsupportedMessage(context, message) @@ -79,6 +84,20 @@ export async function handleMessage(message: Message, context: WebviewContext) { } } +/** + * Method for extracting fileContents from the context object based on different WorkflowStudio modes. + * @param context The context object containing the necessary information for the webview. + */ +function getFileContents(context: WebviewContext): string { + const filePath = context.defaultTemplatePath + if (context.mode === WorkflowMode.Readonly) { + const definitionString = getStateMachineDefinitionFromCfnTemplate(context.stateMachineName, filePath) + return toUnescapedAslJsonString(definitionString || '') + } else { + return context.textDocument.getText().toString() + } +} + /** * Handler for when the webview is ready. * This handler is used to initialize the webview with the contents of the asl file selected. @@ -88,7 +107,7 @@ async function initMessageHandler(context: WebviewContext) { const filePath = context.defaultTemplatePath try { - const fileContents = context.textDocument.getText().toString() + const fileContents = getFileContents(context) context.fileStates[filePath] = { fileContents } await broadcastFileChange(context, 'INITIAL_RENDER') @@ -110,11 +129,15 @@ async function initMessageHandler(context: WebviewContext) { * @param trigger: The action that triggered the change (either initial render or user saving the file) */ export async function broadcastFileChange(context: WebviewContext, trigger: FileChangeEventTrigger) { + const fileContents = getFileContents(context) await context.panel.webview.postMessage({ messageType: MessageType.BROADCAST, command: Command.FILE_CHANGED, - fileName: context.defaultTemplateName, - fileContents: context.textDocument.getText().toString(), + fileName: + context.mode === WorkflowMode.Readonly && context.stateMachineName + ? context.stateMachineName + : context.defaultTemplateName, + fileContents, filePath: context.defaultTemplatePath, trigger, } as FileChangedMessage) diff --git a/packages/core/src/stepFunctions/workflowStudio/types.ts b/packages/core/src/stepFunctions/workflowStudio/types.ts index 3c4bd81b6d7..989ef4517d6 100644 --- a/packages/core/src/stepFunctions/workflowStudio/types.ts +++ b/packages/core/src/stepFunctions/workflowStudio/types.ts @@ -5,7 +5,14 @@ import { IAM, StepFunctions } from 'aws-sdk' import * as vscode from 'vscode' +export enum WorkflowMode { + Editable = 'toolkit', + Readonly = 'readonly', +} + export type WebviewContext = { + stateMachineName: string + mode: WorkflowMode panel: vscode.WebviewPanel textDocument: vscode.TextDocument disposables: vscode.Disposable[] diff --git a/packages/core/src/stepFunctions/workflowStudio/workflowStudioEditor.ts b/packages/core/src/stepFunctions/workflowStudio/workflowStudioEditor.ts index 934d2364bb6..da1a9f2e9bf 100644 --- a/packages/core/src/stepFunctions/workflowStudio/workflowStudioEditor.ts +++ b/packages/core/src/stepFunctions/workflowStudio/workflowStudioEditor.ts @@ -8,7 +8,7 @@ import * as vscode from 'vscode' import { telemetry } from '../../shared/telemetry/telemetry' import { i18n } from '../../shared/i18n-helper' import { broadcastFileChange } from './handleMessage' -import { FileWatchInfo, WebviewContext } from './types' +import { FileWatchInfo, WebviewContext, WorkflowMode } from './types' import { CancellationError } from '../../shared/utilities/timeoutUtils' import { handleMessage } from './handleMessage' import { isInvalidJsonFile } from '../utils' @@ -28,6 +28,8 @@ export class WorkflowStudioEditor { protected isPanelDisposed = false private readonly onVisualizationDisposeEmitter = new vscode.EventEmitter() private fileId: string + private readonly mode: WorkflowMode + private readonly stateMachineName: string public workSpacePath: string public defaultTemplatePath: string public defaultTemplateName: string @@ -39,8 +41,12 @@ export class WorkflowStudioEditor { textDocument: vscode.TextDocument, webviewPanel: vscode.WebviewPanel, fileId: string, - getWebviewContent: () => Promise + getWebviewContent: () => Promise, + mode: WorkflowMode, + stateMachineName: string ) { + this.mode = mode + this.stateMachineName = stateMachineName this.getWebviewContent = getWebviewContent this.documentUri = textDocument.uri this.webviewPanel = webviewPanel @@ -102,6 +108,8 @@ export class WorkflowStudioEditor { fileStates: this.fileStates, loaderNotification: undefined, fileId: this.fileId, + mode: this.mode, + stateMachineName: this.stateMachineName, } void vscode.window.withProgress( @@ -152,6 +160,25 @@ export class WorkflowStudioEditor { }) ) + // When rendering StateMachine Graph from CDK applications, we are getting StateMachine ASL definition from the CloudFormation template produced by `cdk synth` + // Track file content update in the CloudFormation template and update the webview to render the updated StateMachine Graph + if (contextObject.mode === WorkflowMode.Readonly) { + const watcher = vscode.workspace.createFileSystemWatcher(contextObject.textDocument.uri.fsPath) + watcher.onDidChange(async () => { + await telemetry.stepfunctions_saveFile.run(async (span) => { + span.record({ + id: contextObject.fileId, + saveType: 'AUTO_SAVE', + source: 'CFN_TEMPLATE', + isInvalidJson: isInvalidJsonFile(contextObject.textDocument), + }) + await broadcastFileChange(contextObject, 'MANUAL_SAVE') + }) + }) + + contextObject.disposables.push(watcher) + } + // Handle messages from the webview this.disposables.push( this.webviewPanel.webview.onDidReceiveMessage(async (message) => { diff --git a/packages/core/src/stepFunctions/workflowStudio/workflowStudioEditorProvider.ts b/packages/core/src/stepFunctions/workflowStudio/workflowStudioEditorProvider.ts index 7cd60709e2d..797ac80fbe7 100644 --- a/packages/core/src/stepFunctions/workflowStudio/workflowStudioEditorProvider.ts +++ b/packages/core/src/stepFunctions/workflowStudio/workflowStudioEditorProvider.ts @@ -15,6 +15,7 @@ import { getTabSizeSetting } from '../../shared/utilities/editorUtilities' import { WorkflowStudioEditor } from './workflowStudioEditor' import { i18n } from '../../shared/i18n-helper' import { isInvalidJsonFile, isInvalidYamlFile } from '../utils' +import { WorkflowMode } from './types' const isLocalDev = false const localhost = 'http://127.0.0.1:3002' @@ -116,7 +117,7 @@ export class WorkflowStudioEditorProvider implements vscode.CustomTextEditorProv * Gets the webview content for Workflow Studio. * @private */ - private getWebviewContent = async () => { + private getWebviewContent = async (mode: WorkflowMode) => { const htmlFileSplit = this.webviewHtml.split('') // Set asset source to CDN @@ -131,7 +132,8 @@ export class WorkflowStudioEditorProvider implements vscode.CustomTextEditorProv const tabSizeTag = `` const darkModeTag = `` - return `${htmlFileSplit[0]} ${baseTag} ${localeTag} ${darkModeTag} ${tabSizeTag} ${htmlFileSplit[1]}` + const modeTag = `` + return `${htmlFileSplit[0]} ${baseTag} ${localeTag} ${darkModeTag} ${tabSizeTag} ${modeTag} ${htmlFileSplit[1]}` } /** @@ -146,6 +148,9 @@ export class WorkflowStudioEditorProvider implements vscode.CustomTextEditorProv webviewPanel: vscode.WebviewPanel, _token: vscode.CancellationToken ): Promise { + const uriSearchParams = new URLSearchParams(document.uri.query) + const stateMachineName = uriSearchParams.get('statemachineName') || '' + const workflowMode = (uriSearchParams.get('workflowMode') as WorkflowMode) || WorkflowMode.Editable await telemetry.stepfunctions_openWorkflowStudio.run(async () => { const reopenWithDefaultEditor = async () => { await vscode.commands.executeCommand('vscode.openWith', document.uri, 'default') @@ -195,7 +200,9 @@ export class WorkflowStudioEditorProvider implements vscode.CustomTextEditorProv document, webviewPanel, fileId, - this.getWebviewContent + () => this.getWebviewContent(workflowMode), + workflowMode, + stateMachineName ) this.handleNewVisualization(document.uri.fsPath, newVisualization) } catch (err) { diff --git a/packages/core/src/test/stepFunctions/utils.test.ts b/packages/core/src/test/stepFunctions/utils.test.ts index bcc47d54848..2616316b6fd 100644 --- a/packages/core/src/test/stepFunctions/utils.test.ts +++ b/packages/core/src/test/stepFunctions/utils.test.ts @@ -4,175 +4,10 @@ */ import assert from 'assert' -import * as sinon from 'sinon' import * as vscode from 'vscode' -import { makeTemporaryToolkitFolder } from '../../shared/filesystemUtilities' -import { isDocumentValid, isStepFunctionsRole, StateMachineGraphCache } from '../../stepFunctions/utils' -import globals from '../../shared/extensionGlobals' -import { fs } from '../../shared' +import { isDocumentValid, isStepFunctionsRole } from '../../stepFunctions/utils' import { IamRole } from '../../shared/clients/iam' -const requestBody = 'request body string' -const assetUrl = 'https://something' -const filePath = '/some/path' -const storageKey = 'SCRIPT_LAST_DOWNLOADED_URL' -let tempFolder = '' - -describe('StateMachineGraphCache', function () { - before(async function () { - tempFolder = await makeTemporaryToolkitFolder() - }) - - after(async function () { - await fs.delete(tempFolder, { recursive: true, force: true }) - }) - - describe('updateCachedFile', function () { - it('downloads a file when it is not in cache and stores it', async function () { - const getFileData = sinon.stub().resolves(requestBody) - const fileExists = sinon.stub().onFirstCall().resolves(false).onSecondCall().resolves(true) - - const writeFile = sinon.spy() - - const cache = new StateMachineGraphCache({ - getFileData, - fileExists, - writeFile, - cssFilePath: '', - jsFilePath: '', - dirPath: tempFolder, - }) - - await cache.updateCachedFile({ - lastDownloadedURLKey: storageKey, - currentURL: assetUrl, - filePath: filePath, - }) - - assert.deepStrictEqual(globals.globalState.get(storageKey), assetUrl) - assert.ok(writeFile.calledWith(filePath, requestBody)) - }) - - it('downloads and stores a file when cached file exists but url has been updated', async function () { - await globals.globalState.update(storageKey, 'https://old-url') - const getFileData = sinon.stub().resolves(requestBody) - const fileExists = sinon.stub().onFirstCall().resolves(true).onSecondCall().resolves(true) - - const writeFile = sinon.spy() - - const cache = new StateMachineGraphCache({ - getFileData, - fileExists, - writeFile, - cssFilePath: '', - jsFilePath: '', - dirPath: tempFolder, - }) - - await cache.updateCachedFile({ - lastDownloadedURLKey: storageKey, - currentURL: assetUrl, - filePath: filePath, - }) - - assert.deepStrictEqual(globals.globalState.get(storageKey), assetUrl) - assert.ok(writeFile.calledWith(filePath, requestBody)) - }) - - it('it does not store data when file exists and url for it is same', async function () { - await globals.globalState.update(storageKey, assetUrl) - const getFileData = sinon.stub().resolves(requestBody) - const fileExists = sinon.stub().onFirstCall().resolves(true).onSecondCall().resolves(true) - - const writeFile = sinon.spy() - - const cache = new StateMachineGraphCache({ - getFileData, - fileExists, - writeFile, - cssFilePath: '', - jsFilePath: '', - dirPath: '', - }) - - await cache.updateCachedFile({ - lastDownloadedURLKey: storageKey, - currentURL: assetUrl, - filePath: filePath, - }) - - assert.deepStrictEqual(globals.globalState.get(storageKey), assetUrl) - assert.ok(writeFile.notCalled) - }) - it('it passes if both files required exist', async function () { - const getFileData = sinon.stub().resolves(true) - const fileExists = sinon.stub().resolves(true) - - const writeFile = sinon.spy() - - const cache = new StateMachineGraphCache({ - getFileData, - fileExists, - writeFile, - cssFilePath: '', - jsFilePath: '', - dirPath: '', - }) - - await cache.confirmCacheExists() - - assert.ok(fileExists.calledTwice) - }) - it('it rejects if both files required do not exist on filesystem', async function () { - const getFileData = sinon.stub() - const fileExists = sinon.stub().onFirstCall().resolves(true).onSecondCall().resolves(false) - - const writeFile = sinon.spy() - - const cache = new StateMachineGraphCache({ - getFileData, - fileExists, - writeFile, - cssFilePath: 'one', - jsFilePath: 'two', - dirPath: '', - }) - - await assert.rejects(cache.confirmCacheExists()) - }) - - it('creates assets directory when it does not exist', async function () { - const getFileData = sinon.stub().resolves(requestBody) - const fileExists = sinon.stub().onFirstCall().resolves(false).onSecondCall().resolves(false) - - const writeFile = sinon.spy() - const makeDir = sinon.spy() - - const dirPath = '/path/to/assets' - - const cache = new StateMachineGraphCache({ - getFileData, - fileExists, - writeFile, - makeDir, - cssFilePath: '', - jsFilePath: '', - dirPath, - }) - - await cache.updateCachedFile({ - lastDownloadedURLKey: storageKey, - currentURL: assetUrl, - filePath: filePath, - }) - - assert.deepStrictEqual(globals.globalState.get(storageKey), assetUrl) - assert.ok(writeFile.calledWith(filePath, requestBody)) - assert.ok(makeDir.calledWith(dirPath)) - }) - }) -}) - describe('isStepFunctionsRole', function () { const baseIamRole: IamRole = { Path: '', diff --git a/packages/core/src/test/stepFunctions/workflowStudio/workflowStudioApiHandler.test.ts b/packages/core/src/test/stepFunctions/workflowStudio/workflowStudioApiHandler.test.ts index 990629fe1ff..32c9160c1c1 100644 --- a/packages/core/src/test/stepFunctions/workflowStudio/workflowStudioApiHandler.test.ts +++ b/packages/core/src/test/stepFunctions/workflowStudio/workflowStudioApiHandler.test.ts @@ -7,7 +7,13 @@ import assert from 'assert' import sinon from 'sinon' import { WorkflowStudioApiHandler } from '../../../stepFunctions/workflowStudio/workflowStudioApiHandler' import { MockDocument } from '../../fake/fakeDocument' -import { ApiAction, Command, MessageType, WebviewContext } from '../../../stepFunctions/workflowStudio/types' +import { + ApiAction, + Command, + MessageType, + WebviewContext, + WorkflowMode, +} from '../../../stepFunctions/workflowStudio/types' import * as vscode from 'vscode' import { assertTelemetry } from '../../testUtil' import { DefaultStepFunctionsClient } from '../../../shared/clients/stepFunctionsClient' @@ -45,6 +51,8 @@ describe('WorkflowStudioApiHandler', function () { postMessageStub = sinon.stub(panel.webview, 'postMessage') const context: WebviewContext = { + stateMachineName: '', + mode: WorkflowMode.Editable, defaultTemplateName: '', defaultTemplatePath: '', disposables: [], diff --git a/packages/core/src/testLint/eslint.test.ts b/packages/core/src/testLint/eslint.test.ts index ac825d3b2b1..ccf670a1cee 100644 --- a/packages/core/src/testLint/eslint.test.ts +++ b/packages/core/src/testLint/eslint.test.ts @@ -27,8 +27,6 @@ describe('eslint', function () { '**/types/*.d.ts', '--ignore-pattern', '**/src/testFixtures/**', - '--ignore-pattern', - '**/resources/js/graphStateMachine.js', '--ext', '.ts', '../amazonq', diff --git a/packages/toolkit/.changes/next-release/Feature-bd80ac3c-3d3c-45c3-aa6a-16231a63c43d.json b/packages/toolkit/.changes/next-release/Feature-bd80ac3c-3d3c-45c3-aa6a-16231a63c43d.json new file mode 100644 index 00000000000..58bc94abb0b --- /dev/null +++ b/packages/toolkit/.changes/next-release/Feature-bd80ac3c-3d3c-45c3-aa6a-16231a63c43d.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "Step Functions: Use WorkflowStudio to render StateMachine Graph in CDK applications" +} From bb5c10ad227b73bf596282ae6d81310d98ed364e Mon Sep 17 00:00:00 2001 From: Karan Ahluwalia Date: Wed, 2 Apr 2025 12:22:24 -0700 Subject: [PATCH 04/17] feat(amazonq): get logs generated by the Agent #6832 ## Problem Users currently cannot access logs generated by the Agent when it executes user commands during code generation. ## Solution The output generated by the Agent during user command execution will be captured and written to the user's `logs`. This ensures that: - Command execution logs are persisted locally - Users can access the logs directly from their repository for troubleshooting. --- ...-86f056f5-4ac4-47be-8167-09c19a529a1e.json | 4 + .../core/src/amazonq/session/sessionState.ts | 13 ++ .../test/amazonq/session/sessionState.test.ts | 153 ++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 packages/amazonq/.changes/next-release/Feature-86f056f5-4ac4-47be-8167-09c19a529a1e.json create mode 100644 packages/core/src/test/amazonq/session/sessionState.test.ts diff --git a/packages/amazonq/.changes/next-release/Feature-86f056f5-4ac4-47be-8167-09c19a529a1e.json b/packages/amazonq/.changes/next-release/Feature-86f056f5-4ac4-47be-8167-09c19a529a1e.json new file mode 100644 index 00000000000..46bf3016553 --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-86f056f5-4ac4-47be-8167-09c19a529a1e.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "Save user command execution logs to plugin output." +} diff --git a/packages/core/src/amazonq/session/sessionState.ts b/packages/core/src/amazonq/session/sessionState.ts index d2d81ae6670..1f206c23159 100644 --- a/packages/core/src/amazonq/session/sessionState.ts +++ b/packages/core/src/amazonq/session/sessionState.ts @@ -27,8 +27,10 @@ import { } from '../commons/types' import { prepareRepoData, getDeletedFileInfos, registerNewFiles, PrepareRepoDataOptions } from '../util/files' import { uploadCode } from '../util/upload' +import { truncate } from '../../shared/utilities/textUtilities' export const EmptyCodeGenID = 'EMPTY_CURRENT_CODE_GENERATION_ID' +export const RunCommandLogFileName = '.amazonq/dev/run_command.log' export interface BaseMessenger { sendAnswer(params: any): void @@ -103,6 +105,17 @@ export abstract class CodeGenBase { case CodeGenerationStatus.COMPLETE: { const { newFileContents, deletedFiles, references } = await this.config.proxyClient.exportResultArchive(this.conversationId) + + const logFileInfo = newFileContents.find( + (file: { zipFilePath: string; fileContent: string }) => + file.zipFilePath === RunCommandLogFileName + ) + if (logFileInfo) { + logFileInfo.fileContent = truncate(logFileInfo.fileContent, 10000000, '\n... [truncated]') // Limit to max 20MB + getLogger().info(`sessionState: Run Command logs, ${logFileInfo.fileContent}`) + newFileContents.splice(newFileContents.indexOf(logFileInfo), 1) + } + const newFileInfo = registerNewFiles( fs, newFileContents, diff --git a/packages/core/src/test/amazonq/session/sessionState.test.ts b/packages/core/src/test/amazonq/session/sessionState.test.ts new file mode 100644 index 00000000000..dcff3398cea --- /dev/null +++ b/packages/core/src/test/amazonq/session/sessionState.test.ts @@ -0,0 +1,153 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import sinon from 'sinon' +import { CodeGenBase } from '../../../amazonq/session/sessionState' +import { RunCommandLogFileName } from '../../../amazonq/session/sessionState' +import assert from 'assert' +import * as workspaceUtils from '../../../shared/utilities/workspaceUtils' +import { TelemetryHelper } from '../../../amazonq/util/telemetryHelper' +import { assertLogsContain } from '../../globalSetup.test' + +describe('CodeGenBase generateCode log file handling', () => { + class TestCodeGen extends CodeGenBase { + public generatedFiles: any[] = [] + constructor(config: any, tabID: string) { + super(config, tabID) + } + protected handleProgress(_messenger: any): void { + // No-op for test. + } + protected getScheme(): string { + return 'file' + } + protected getTimeoutErrorCode(): string { + return 'test_timeout' + } + protected handleGenerationComplete(_messenger: any, newFileInfo: any[]): void { + this.generatedFiles = newFileInfo + } + protected handleError(_messenger: any, _codegenResult: any): Error { + throw new Error('handleError called') + } + } + + let fakeProxyClient: any + let testConfig: any + let fsMock: any + let messengerMock: any + let testAction: any + + beforeEach(async () => { + const ret = { + testworkspacefolder: { + uri: vscode.Uri.file('/path/to/testworkspacefolder'), + name: 'testworkspacefolder', + index: 0, + }, + } + sinon.stub(workspaceUtils, 'getWorkspaceFoldersByPrefixes').returns(ret) + + fakeProxyClient = { + getCodeGeneration: sinon.stub().resolves({ + codeGenerationStatus: { status: 'Complete' }, + codeGenerationRemainingIterationCount: 0, + codeGenerationTotalIterationCount: 1, + }), + exportResultArchive: sinon.stub(), + } + + testConfig = { + conversationId: 'conv_test', + uploadId: 'upload_test', + workspaceRoots: ['/path/to/testworkspacefolder'], + proxyClient: fakeProxyClient, + } + + fsMock = { + writeFile: sinon.stub().resolves(), + registerProvider: sinon.stub().resolves(), + } + + messengerMock = { sendAnswer: sinon.spy() } + + testAction = { + fs: fsMock, + messenger: messengerMock, + tokenSource: { + token: { + isCancellationRequested: false, + onCancellationRequested: () => {}, + }, + }, + } + }) + + afterEach(() => { + sinon.restore() + }) + + const runGenerateCode = async (codeGenerationId: string) => { + const testCodeGen = new TestCodeGen(testConfig, 'tab1') + return await testCodeGen.generateCode({ + messenger: messengerMock, + fs: fsMock, + codeGenerationId, + telemetry: new TelemetryHelper(), + workspaceFolders: [testConfig.workspaceRoots[0]], + action: testAction, + }) + } + + const createExpectedNewFile = (fileObj: { zipFilePath: string; fileContent: string }) => ({ + zipFilePath: fileObj.zipFilePath, + fileContent: fileObj.fileContent, + changeApplied: false, + rejected: false, + relativePath: fileObj.zipFilePath, + virtualMemoryUri: vscode.Uri.file(`/upload_test/${fileObj.zipFilePath}`), + workspaceFolder: { + index: 0, + name: 'testworkspacefolder', + uri: vscode.Uri.file('/path/to/testworkspacefolder'), + }, + }) + + it('adds the log content to logger if present and excludes it from new files', async () => { + const logFileInfo = { + zipFilePath: RunCommandLogFileName, + fileContent: 'Log content', + } + const otherFile = { zipFilePath: 'other.ts', fileContent: 'other content' } + fakeProxyClient.exportResultArchive.resolves({ + newFileContents: [logFileInfo, otherFile], + deletedFiles: [], + references: [], + }) + const result = await runGenerateCode('codegen1') + + assertLogsContain(`sessionState: Run Command logs, Log content`, false, 'info') + + const expectedNewFile = createExpectedNewFile(otherFile) + assert.deepStrictEqual(result.newFiles[0].fileContent, expectedNewFile.fileContent) + }) + + it('skips log file handling if log file is not present', async () => { + const file1 = { zipFilePath: 'file1.ts', fileContent: 'content1' } + fakeProxyClient.exportResultArchive.resolves({ + newFileContents: [file1], + deletedFiles: [], + references: [], + }) + + const result = await runGenerateCode('codegen2') + + assert.throws(() => assertLogsContain(`sessionState: Run Command logs, Log content`, false, 'info')) + + const expectedNewFile = createExpectedNewFile(file1) + assert.deepStrictEqual(result.newFiles[0].fileContent, expectedNewFile.fileContent) + }) +}) From 47774419dbfffe8ae2089025c0d784ef3c0e5a2a Mon Sep 17 00:00:00 2001 From: Avi Alpert <131792194+avi-alpert@users.noreply.github.com> Date: Wed, 2 Apr 2025 16:39:20 -0400 Subject: [PATCH 05/17] fix(amazonq): export/delete icons are swapped #6915 --- packages/core/src/shared/db/chatDb/util.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/shared/db/chatDb/util.ts b/packages/core/src/shared/db/chatDb/util.ts index a176db4ff40..5a7bd5e23c1 100644 --- a/packages/core/src/shared/db/chatDb/util.ts +++ b/packages/core/src/shared/db/chatDb/util.ts @@ -193,12 +193,12 @@ export function groupTabsByDate(tabs: Tab[]): DetailedListItemGroup[] { const getConversationActions = (historyId: string): ChatItemButton[] => [ { text: 'Export', - icon: 'trash' as MynahIconsType, + icon: 'external' as MynahIconsType, id: historyId, }, { text: 'Delete', - icon: 'external' as MynahIconsType, + icon: 'trash' as MynahIconsType, id: historyId, }, ] From b097fbf828b3035514d822f5e995c114ea9799f0 Mon Sep 17 00:00:00 2001 From: vicheey <181402101+vicheey@users.noreply.github.com> Date: Thu, 3 Apr 2025 07:55:34 -0700 Subject: [PATCH 06/17] fix(lambda): flaky test due to terminal instance #6920 ## Problem unreliable test fix https://github.com/aws/aws-toolkit-vscode/issues/6913 ## Solution Move the spy instantiation closer to reduce side effects. Add delay before start running command so additional for clean up to reduce chance of flakiness. Change assertion from true/false to count comparison to give more info for debugging in case the flakiness persists into the future. --- .../core/src/test/shared/sam/build.test.ts | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/core/src/test/shared/sam/build.test.ts b/packages/core/src/test/shared/sam/build.test.ts index 52a537f750c..fb28d8528db 100644 --- a/packages/core/src/test/shared/sam/build.test.ts +++ b/packages/core/src/test/shared/sam/build.test.ts @@ -280,8 +280,6 @@ describe('SAM runBuild', () => { let mockChildProcessClass: sinon.SinonStub let mockSamBuildChildProcess: sinon.SinonStub - let spyRunInterminal: sinon.SinonSpy - let registry: CloudFormationTemplateRegistry // Dependency clients @@ -296,8 +294,6 @@ describe('SAM runBuild', () => { templateFile = vscode.Uri.file(await testFolder.write('template.yaml', validTemplateData)) await registry.addItem(templateFile) - spyRunInterminal = sandbox.spy(ProcessTerminalUtils, 'runInTerminal') - mockGetSpawnEnv = sandbox.stub(ResolveEnvModule, 'getSpawnEnv').callsFake( sandbox.stub().resolves({ AWS_TOOLING_USER_AGENT: 'AWS-Toolkit-For-VSCode/testPluginVersion', @@ -312,6 +308,8 @@ describe('SAM runBuild', () => { }) describe(':) path', () => { + let spyRunInterminal: sinon.SinonSpy + beforeEach(() => { mockGetSamCliPath = sandbox .stub(SamUtilsModule, 'getSamCliPathAndVersion') @@ -329,6 +327,7 @@ describe('SAM runBuild', () => { }), }, }) + spyRunInterminal = sandbox.spy(ProcessTerminalUtils, 'runInTerminal') mockChildProcessClass = sandbox.stub(ProcessUtilsModule, 'ChildProcess').returns(mockSamBuildChildProcess) }) @@ -337,10 +336,11 @@ describe('SAM runBuild', () => { }) const verifyCorrectDependencyCall = () => { - assert(mockGetSamCliPath.calledOnce) - assert(mockChildProcessClass.calledOnce) - assert(mockGetSpawnEnv.calledOnce) - assert(spyRunInterminal.calledOnce) + // Prefer count comparison for debugging flakiness + assert.strictEqual(mockGetSamCliPath.callCount, 1) + assert.strictEqual(mockChildProcessClass.callCount, 1) + assert.strictEqual(mockGetSpawnEnv.callCount, 1) + assert.strictEqual(spyRunInterminal.callCount, 1) assert.deepEqual(spyRunInterminal.getCall(0).args, [mockSamBuildChildProcess, 'build']) } @@ -398,7 +398,8 @@ describe('SAM runBuild', () => { .build() // Invoke sync command from command palette - await runBuild() + // Instead of await runBuild(), prefer this to avoid flakiness due to race condition + await delayedRunBuild() assert.deepEqual(mockChildProcessClass.getCall(0).args, [ 'sam-cli-path', @@ -433,7 +434,8 @@ describe('SAM runBuild', () => { projectRoot: projectRoot, } - await runBuild(new AppNode(expectedSamAppLocation)) + // Instead of await runBuild(), prefer this to avoid flakiness due to race condition + await delayedRunBuild(expectedSamAppLocation) getTestWindow() .getFirstMessage() @@ -509,7 +511,8 @@ describe('SAM runBuild', () => { }) .build() - await runBuild() + // Instead of await runBuild(), prefer this to avoid flakiness due to race condition + await delayedRunBuild() assert.deepEqual(mockChildProcessClass.getCall(0).args, [ 'sam-cli-path', @@ -605,14 +608,14 @@ async function runInParallel(samLocation: SamAppLocation): Promise<[SamBuildResu } // We add a small delay to avoid the unlikely but possible race condition. -async function delayedRunBuild(samLocation: SamAppLocation): Promise { +async function delayedRunBuild(samLocation?: SamAppLocation): Promise { return new Promise(async (resolve, reject) => { // Add a small delay before returning the build promise setTimeout(() => { // Do nothing, just let the delay pass }, 20) - const buildPromise = runBuild(new AppNode(samLocation)) + const buildPromise = samLocation ? runBuild(new AppNode(samLocation)) : runBuild() buildPromise.then(resolve).catch(reject) }) } From e839dba7ebaefcb1219c725e8b937910b2daa9da Mon Sep 17 00:00:00 2001 From: Avi Alpert <131792194+avi-alpert@users.noreply.github.com> Date: Thu, 3 Apr 2025 14:00:03 -0400 Subject: [PATCH 07/17] fix(amazonq): hide export chat button #6923 ## Problem clicking the Export button on a fresh conversation doesn't include answers from Q in the exported markdown/html. Answers are included if Export is clicked after closing/reopening IDE ## Solution hide export chat from tab bar --- .../Feature-ed1e1b2e-0073-4de0-99b8-e417298328e6.json | 4 ---- packages/core/src/amazonq/webview/ui/main.ts | 3 ++- packages/core/src/shared/db/chatDb/util.ts | 3 ++- 3 files changed, 4 insertions(+), 6 deletions(-) delete mode 100644 packages/amazonq/.changes/next-release/Feature-ed1e1b2e-0073-4de0-99b8-e417298328e6.json diff --git a/packages/amazonq/.changes/next-release/Feature-ed1e1b2e-0073-4de0-99b8-e417298328e6.json b/packages/amazonq/.changes/next-release/Feature-ed1e1b2e-0073-4de0-99b8-e417298328e6.json deleted file mode 100644 index 11b844c5788..00000000000 --- a/packages/amazonq/.changes/next-release/Feature-ed1e1b2e-0073-4de0-99b8-e417298328e6.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Feature", - "description": "Amazon Q chat: Click share icon to export chat to Markdown or HTML" -} diff --git a/packages/core/src/amazonq/webview/ui/main.ts b/packages/core/src/amazonq/webview/ui/main.ts index 5f1254dc3b1..d7da8416f7b 100644 --- a/packages/core/src/amazonq/webview/ui/main.ts +++ b/packages/core/src/amazonq/webview/ui/main.ts @@ -969,11 +969,12 @@ export const createMynahUI = ( icon: MynahIcons.COMMENT, description: 'View chat history', }, + /* Temporarily hide export chat button from tab bar { id: 'export_chat', icon: MynahIcons.EXTERNAL, description: 'Export chat', - }, + }, */ ], }, }) diff --git a/packages/core/src/shared/db/chatDb/util.ts b/packages/core/src/shared/db/chatDb/util.ts index 5a7bd5e23c1..5c260be60b5 100644 --- a/packages/core/src/shared/db/chatDb/util.ts +++ b/packages/core/src/shared/db/chatDb/util.ts @@ -191,11 +191,12 @@ export function groupTabsByDate(tabs: Tab[]): DetailedListItemGroup[] { } const getConversationActions = (historyId: string): ChatItemButton[] => [ + /* Temporarily hide export chat button from tab bar { text: 'Export', icon: 'external' as MynahIconsType, id: historyId, - }, + }, */ { text: 'Delete', icon: 'trash' as MynahIconsType, From 8ae08de1aecc8ef3f8c939bd7c2c8bd18d25856d Mon Sep 17 00:00:00 2001 From: aws-toolkit-automation <> Date: Thu, 3 Apr 2025 18:04:47 +0000 Subject: [PATCH 08/17] Release 3.53.0 --- package-lock.json | 4 ++-- packages/toolkit/.changes/3.53.0.json | 10 ++++++++++ .../Feature-bd80ac3c-3d3c-45c3-aa6a-16231a63c43d.json | 4 ---- packages/toolkit/CHANGELOG.md | 4 ++++ packages/toolkit/package.json | 2 +- 5 files changed, 17 insertions(+), 7 deletions(-) create mode 100644 packages/toolkit/.changes/3.53.0.json delete mode 100644 packages/toolkit/.changes/next-release/Feature-bd80ac3c-3d3c-45c3-aa6a-16231a63c43d.json diff --git a/package-lock.json b/package-lock.json index 38131b6a7c5..d5c6caca367 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "prettier": "^3.3.3", "prettier-plugin-sh": "^0.14.0", "pretty-quick": "^4.0.0", - "ts-node": "^10.9.1", + "ts-node": "^10.9.2", "typescript": "^5.0.4", "webpack": "^5.95.0", "webpack-cli": "^5.1.4", @@ -28588,7 +28588,7 @@ }, "packages/toolkit": { "name": "aws-toolkit-vscode", - "version": "3.53.0-SNAPSHOT", + "version": "3.53.0", "license": "Apache-2.0", "dependencies": { "aws-core-vscode": "file:../core/" diff --git a/packages/toolkit/.changes/3.53.0.json b/packages/toolkit/.changes/3.53.0.json new file mode 100644 index 00000000000..79344520031 --- /dev/null +++ b/packages/toolkit/.changes/3.53.0.json @@ -0,0 +1,10 @@ +{ + "date": "2025-04-03", + "version": "3.53.0", + "entries": [ + { + "type": "Feature", + "description": "Step Functions: Use WorkflowStudio to render StateMachine Graph in CDK applications" + } + ] +} \ No newline at end of file diff --git a/packages/toolkit/.changes/next-release/Feature-bd80ac3c-3d3c-45c3-aa6a-16231a63c43d.json b/packages/toolkit/.changes/next-release/Feature-bd80ac3c-3d3c-45c3-aa6a-16231a63c43d.json deleted file mode 100644 index 58bc94abb0b..00000000000 --- a/packages/toolkit/.changes/next-release/Feature-bd80ac3c-3d3c-45c3-aa6a-16231a63c43d.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Feature", - "description": "Step Functions: Use WorkflowStudio to render StateMachine Graph in CDK applications" -} diff --git a/packages/toolkit/CHANGELOG.md b/packages/toolkit/CHANGELOG.md index 34a9087d5c4..ef72984cca3 100644 --- a/packages/toolkit/CHANGELOG.md +++ b/packages/toolkit/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.53.0 2025-04-03 + +- **Feature** Step Functions: Use WorkflowStudio to render StateMachine Graph in CDK applications + ## 3.52.0 2025-03-28 - **Bug Fix** SAM build: prevent running multiple build processes for the same template diff --git a/packages/toolkit/package.json b/packages/toolkit/package.json index ee842e6fcb4..d8fa1304e62 100644 --- a/packages/toolkit/package.json +++ b/packages/toolkit/package.json @@ -2,7 +2,7 @@ "name": "aws-toolkit-vscode", "displayName": "AWS Toolkit", "description": "Including CodeCatalyst, Infrastructure Composer, and support for Lambda, S3, CloudWatch Logs, CloudFormation, and many other services.", - "version": "3.53.0-SNAPSHOT", + "version": "3.53.0", "extensionKind": [ "workspace" ], From 79169782a7c62f7ed418b62d746e18f5adb08cd0 Mon Sep 17 00:00:00 2001 From: aws-toolkit-automation <> Date: Thu, 3 Apr 2025 18:05:17 +0000 Subject: [PATCH 09/17] Release 1.54.0 --- package-lock.json | 4 +- packages/amazonq/.changes/1.54.0.json | 38 +++++++++++++++++++ ...-ccbc5e50-0a90-4340-b6d5-e57537949898.json | 4 -- ...-2cf2317a-aa6e-4413-aa57-e0d8905ab109.json | 4 -- ...-390d2096-ea1e-43b9-a889-2494d6e9e6d3.json | 4 -- ...-3c42f632-46b8-4e59-b5fa-75d638f3f08d.json | 4 -- ...-77c028a2-092e-4ee6-b782-d6ddc930f304.json | 4 -- ...-80e49868-8e04-4648-aace-ad34ef57eba2.json | 4 -- ...-86f056f5-4ac4-47be-8167-09c19a529a1e.json | 4 -- ...-e5ed7aac-542d-41ee-8abe-d32db5de73ae.json | 4 -- packages/amazonq/CHANGELOG.md | 11 ++++++ packages/amazonq/package.json | 2 +- 12 files changed, 52 insertions(+), 35 deletions(-) create mode 100644 packages/amazonq/.changes/1.54.0.json delete mode 100644 packages/amazonq/.changes/next-release/Bug Fix-ccbc5e50-0a90-4340-b6d5-e57537949898.json delete mode 100644 packages/amazonq/.changes/next-release/Feature-2cf2317a-aa6e-4413-aa57-e0d8905ab109.json delete mode 100644 packages/amazonq/.changes/next-release/Feature-390d2096-ea1e-43b9-a889-2494d6e9e6d3.json delete mode 100644 packages/amazonq/.changes/next-release/Feature-3c42f632-46b8-4e59-b5fa-75d638f3f08d.json delete mode 100644 packages/amazonq/.changes/next-release/Feature-77c028a2-092e-4ee6-b782-d6ddc930f304.json delete mode 100644 packages/amazonq/.changes/next-release/Feature-80e49868-8e04-4648-aace-ad34ef57eba2.json delete mode 100644 packages/amazonq/.changes/next-release/Feature-86f056f5-4ac4-47be-8167-09c19a529a1e.json delete mode 100644 packages/amazonq/.changes/next-release/Feature-e5ed7aac-542d-41ee-8abe-d32db5de73ae.json diff --git a/package-lock.json b/package-lock.json index 38131b6a7c5..6620f1dc27a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "prettier": "^3.3.3", "prettier-plugin-sh": "^0.14.0", "pretty-quick": "^4.0.0", - "ts-node": "^10.9.1", + "ts-node": "^10.9.2", "typescript": "^5.0.4", "webpack": "^5.95.0", "webpack-cli": "^5.1.4", @@ -26713,7 +26713,7 @@ }, "packages/amazonq": { "name": "amazon-q-vscode", - "version": "1.54.0-SNAPSHOT", + "version": "1.54.0", "license": "Apache-2.0", "dependencies": { "aws-core-vscode": "file:../core/" diff --git a/packages/amazonq/.changes/1.54.0.json b/packages/amazonq/.changes/1.54.0.json new file mode 100644 index 00000000000..32e6ccabf4a --- /dev/null +++ b/packages/amazonq/.changes/1.54.0.json @@ -0,0 +1,38 @@ +{ + "date": "2025-04-03", + "version": "1.54.0", + "entries": [ + { + "type": "Bug Fix", + "description": "Amazon Q chat: `@prompts` not added to context" + }, + { + "type": "Feature", + "description": "Amazon Q chat: View and search chat history" + }, + { + "type": "Feature", + "description": "SageMaker Unified Studio: Disable Sign out" + }, + { + "type": "Feature", + "description": "SageMaker Unified Studio: Update Q Chat Introduction message" + }, + { + "type": "Feature", + "description": "/review: automatically generate fix without clicking Generate Fix button" + }, + { + "type": "Feature", + "description": "Amazon Q chat: Automatically persist chats between IDE sessions" + }, + { + "type": "Feature", + "description": "Save user command execution logs to plugin output." + }, + { + "type": "Feature", + "description": "Amazon Q chat: Code blocks in chat messages have a max-height of 21 lines and can be scrolled inside" + } + ] +} \ No newline at end of file diff --git a/packages/amazonq/.changes/next-release/Bug Fix-ccbc5e50-0a90-4340-b6d5-e57537949898.json b/packages/amazonq/.changes/next-release/Bug Fix-ccbc5e50-0a90-4340-b6d5-e57537949898.json deleted file mode 100644 index 659d32dfc59..00000000000 --- a/packages/amazonq/.changes/next-release/Bug Fix-ccbc5e50-0a90-4340-b6d5-e57537949898.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Bug Fix", - "description": "Amazon Q chat: `@prompts` not added to context" -} diff --git a/packages/amazonq/.changes/next-release/Feature-2cf2317a-aa6e-4413-aa57-e0d8905ab109.json b/packages/amazonq/.changes/next-release/Feature-2cf2317a-aa6e-4413-aa57-e0d8905ab109.json deleted file mode 100644 index bbe5023b9e4..00000000000 --- a/packages/amazonq/.changes/next-release/Feature-2cf2317a-aa6e-4413-aa57-e0d8905ab109.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Feature", - "description": "Amazon Q chat: View and search chat history" -} diff --git a/packages/amazonq/.changes/next-release/Feature-390d2096-ea1e-43b9-a889-2494d6e9e6d3.json b/packages/amazonq/.changes/next-release/Feature-390d2096-ea1e-43b9-a889-2494d6e9e6d3.json deleted file mode 100644 index 8bb3bff4eb4..00000000000 --- a/packages/amazonq/.changes/next-release/Feature-390d2096-ea1e-43b9-a889-2494d6e9e6d3.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Feature", - "description": "SageMaker Unified Studio: Disable Sign out" -} diff --git a/packages/amazonq/.changes/next-release/Feature-3c42f632-46b8-4e59-b5fa-75d638f3f08d.json b/packages/amazonq/.changes/next-release/Feature-3c42f632-46b8-4e59-b5fa-75d638f3f08d.json deleted file mode 100644 index 9b4965a6fa4..00000000000 --- a/packages/amazonq/.changes/next-release/Feature-3c42f632-46b8-4e59-b5fa-75d638f3f08d.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Feature", - "description": "SageMaker Unified Studio: Update Q Chat Introduction message" -} diff --git a/packages/amazonq/.changes/next-release/Feature-77c028a2-092e-4ee6-b782-d6ddc930f304.json b/packages/amazonq/.changes/next-release/Feature-77c028a2-092e-4ee6-b782-d6ddc930f304.json deleted file mode 100644 index 1ffed6ea405..00000000000 --- a/packages/amazonq/.changes/next-release/Feature-77c028a2-092e-4ee6-b782-d6ddc930f304.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Feature", - "description": "/review: automatically generate fix without clicking Generate Fix button" -} diff --git a/packages/amazonq/.changes/next-release/Feature-80e49868-8e04-4648-aace-ad34ef57eba2.json b/packages/amazonq/.changes/next-release/Feature-80e49868-8e04-4648-aace-ad34ef57eba2.json deleted file mode 100644 index 4fa38b0c059..00000000000 --- a/packages/amazonq/.changes/next-release/Feature-80e49868-8e04-4648-aace-ad34ef57eba2.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Feature", - "description": "Amazon Q chat: Automatically persist chats between IDE sessions" -} diff --git a/packages/amazonq/.changes/next-release/Feature-86f056f5-4ac4-47be-8167-09c19a529a1e.json b/packages/amazonq/.changes/next-release/Feature-86f056f5-4ac4-47be-8167-09c19a529a1e.json deleted file mode 100644 index 46bf3016553..00000000000 --- a/packages/amazonq/.changes/next-release/Feature-86f056f5-4ac4-47be-8167-09c19a529a1e.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Feature", - "description": "Save user command execution logs to plugin output." -} diff --git a/packages/amazonq/.changes/next-release/Feature-e5ed7aac-542d-41ee-8abe-d32db5de73ae.json b/packages/amazonq/.changes/next-release/Feature-e5ed7aac-542d-41ee-8abe-d32db5de73ae.json deleted file mode 100644 index 028feced804..00000000000 --- a/packages/amazonq/.changes/next-release/Feature-e5ed7aac-542d-41ee-8abe-d32db5de73ae.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Feature", - "description": "Amazon Q chat: Code blocks in chat messages have a max-height of 21 lines and can be scrolled inside" -} diff --git a/packages/amazonq/CHANGELOG.md b/packages/amazonq/CHANGELOG.md index c799cfbc69b..2d5fa9eb411 100644 --- a/packages/amazonq/CHANGELOG.md +++ b/packages/amazonq/CHANGELOG.md @@ -1,3 +1,14 @@ +## 1.54.0 2025-04-03 + +- **Bug Fix** Amazon Q chat: `@prompts` not added to context +- **Feature** Amazon Q chat: View and search chat history +- **Feature** SageMaker Unified Studio: Disable Sign out +- **Feature** SageMaker Unified Studio: Update Q Chat Introduction message +- **Feature** /review: automatically generate fix without clicking Generate Fix button +- **Feature** Amazon Q chat: Automatically persist chats between IDE sessions +- **Feature** Save user command execution logs to plugin output. +- **Feature** Amazon Q chat: Code blocks in chat messages have a max-height of 21 lines and can be scrolled inside + ## 1.53.0 2025-03-28 - **Bug Fix** Amazon Q Chat: Choosing a nested subfolder for `/doc` on Windows results in `The folder you chose did not contain any source files` error diff --git a/packages/amazonq/package.json b/packages/amazonq/package.json index 053092dbc4f..d7dcdba1a38 100644 --- a/packages/amazonq/package.json +++ b/packages/amazonq/package.json @@ -2,7 +2,7 @@ "name": "amazon-q-vscode", "displayName": "Amazon Q", "description": "The most capable generative AI-powered assistant for building, operating, and transforming software, with advanced capabilities for managing data and AI", - "version": "1.54.0-SNAPSHOT", + "version": "1.54.0", "extensionKind": [ "workspace" ], From b57a2181bd53da9dcaa282aaae7b2a4a1e363463 Mon Sep 17 00:00:00 2001 From: aws-toolkit-automation <> Date: Thu, 3 Apr 2025 18:37:18 +0000 Subject: [PATCH 10/17] Update version to snapshot version: 3.54.0-SNAPSHOT --- package-lock.json | 4 ++-- packages/toolkit/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d5c6caca367..04dbbe1b3ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "prettier": "^3.3.3", "prettier-plugin-sh": "^0.14.0", "pretty-quick": "^4.0.0", - "ts-node": "^10.9.2", + "ts-node": "^10.9.1", "typescript": "^5.0.4", "webpack": "^5.95.0", "webpack-cli": "^5.1.4", @@ -28588,7 +28588,7 @@ }, "packages/toolkit": { "name": "aws-toolkit-vscode", - "version": "3.53.0", + "version": "3.54.0-SNAPSHOT", "license": "Apache-2.0", "dependencies": { "aws-core-vscode": "file:../core/" diff --git a/packages/toolkit/package.json b/packages/toolkit/package.json index d8fa1304e62..ac20f9e163a 100644 --- a/packages/toolkit/package.json +++ b/packages/toolkit/package.json @@ -2,7 +2,7 @@ "name": "aws-toolkit-vscode", "displayName": "AWS Toolkit", "description": "Including CodeCatalyst, Infrastructure Composer, and support for Lambda, S3, CloudWatch Logs, CloudFormation, and many other services.", - "version": "3.53.0", + "version": "3.54.0-SNAPSHOT", "extensionKind": [ "workspace" ], From 9a6781a26b800192789da8dd6e8085c5c7783101 Mon Sep 17 00:00:00 2001 From: aws-toolkit-automation <> Date: Thu, 3 Apr 2025 18:39:42 +0000 Subject: [PATCH 11/17] Update version to snapshot version: 1.55.0-SNAPSHOT --- package-lock.json | 4 ++-- packages/amazonq/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6620f1dc27a..46379a6d880 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "prettier": "^3.3.3", "prettier-plugin-sh": "^0.14.0", "pretty-quick": "^4.0.0", - "ts-node": "^10.9.2", + "ts-node": "^10.9.1", "typescript": "^5.0.4", "webpack": "^5.95.0", "webpack-cli": "^5.1.4", @@ -26713,7 +26713,7 @@ }, "packages/amazonq": { "name": "amazon-q-vscode", - "version": "1.54.0", + "version": "1.55.0-SNAPSHOT", "license": "Apache-2.0", "dependencies": { "aws-core-vscode": "file:../core/" diff --git a/packages/amazonq/package.json b/packages/amazonq/package.json index d7dcdba1a38..c12dc11e5bd 100644 --- a/packages/amazonq/package.json +++ b/packages/amazonq/package.json @@ -2,7 +2,7 @@ "name": "amazon-q-vscode", "displayName": "Amazon Q", "description": "The most capable generative AI-powered assistant for building, operating, and transforming software, with advanced capabilities for managing data and AI", - "version": "1.54.0", + "version": "1.55.0-SNAPSHOT", "extensionKind": [ "workspace" ], From 4606f5e728e74eb5b4c4830df145ce93f732f1a3 Mon Sep 17 00:00:00 2001 From: zuoyaofu Date: Thu, 3 Apr 2025 11:48:00 -0700 Subject: [PATCH 12/17] test(amazonq): skip exact finding test #6926 ## Problem /review test is flaky, but I was not able to reproduce. ## Solution skip this test for this week to unblock --- packages/amazonq/test/e2e/amazonq/review.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/amazonq/test/e2e/amazonq/review.test.ts b/packages/amazonq/test/e2e/amazonq/review.test.ts index 2f867b8df78..5828d58e8d2 100644 --- a/packages/amazonq/test/e2e/amazonq/review.test.ts +++ b/packages/amazonq/test/e2e/amazonq/review.test.ts @@ -181,7 +181,7 @@ describe('Amazon Q Code Review', function () { await validateInitialChatMessage() }) - it('/review file gives correct critical and high security issues', async () => { + it.skip('/review file gives correct critical and high security issues', async () => { const document = await vscode.workspace.openTextDocument(filePath) await vscode.window.showTextDocument(document) From 91859e29b26ef1c58cbd957d81e1a0deb01a7880 Mon Sep 17 00:00:00 2001 From: vicheey <181402101+vicheey@users.noreply.github.com> Date: Thu, 3 Apr 2025 11:57:46 -0700 Subject: [PATCH 13/17] fix(lambda): flakiness due to existing template.yaml #6924 ## Problem fix https://github.com/aws/aws-toolkit-vscode/issues/6921 ## Solution Refactor the tests for cleaner creating and deletion template.yaml file. - Scope and group tests cases based on required starting template. - Update assertion and error message. --- .../awsService/appBuilder/walkthrough.test.ts | 143 ++++++++++-------- .../core/src/test/shared/sam/build.test.ts | 10 +- 2 files changed, 83 insertions(+), 70 deletions(-) diff --git a/packages/core/src/test/awsService/appBuilder/walkthrough.test.ts b/packages/core/src/test/awsService/appBuilder/walkthrough.test.ts index b6a8d6d662a..44a31b3cae9 100644 --- a/packages/core/src/test/awsService/appBuilder/walkthrough.test.ts +++ b/packages/core/src/test/awsService/appBuilder/walkthrough.test.ts @@ -166,82 +166,93 @@ describe('AppBuilder Walkthrough', function () { const prevInfo = 'random text' assert.ok(workspaceUri) - before(async function () { - await fs.delete(vscode.Uri.joinPath(workspaceUri, 'template.yaml'), { force: true }) - }) - - beforeEach(async function () { - await fs.writeFile(vscode.Uri.joinPath(workspaceUri, 'template.yaml'), prevInfo) - }) - - afterEach(async function () { - await fs.delete(vscode.Uri.joinPath(workspaceUri, 'template.yaml'), { force: true }) - }) + describe('start without existing template', async () => { + beforeEach(async () => { + await fs.delete(vscode.Uri.joinPath(workspaceUri, 'template.yaml'), { force: true }) + }) - it('open existing template', async function () { - // Given no template exist - await fs.delete(vscode.Uri.joinPath(workspaceUri, 'template.yaml'), { force: true }) - // When - await genWalkthroughProject('CustomTemplate', workspaceUri, undefined) - // Then nothing should be created - assert.equal(await fs.exists(vscode.Uri.joinPath(workspaceUri, 'template.yaml')), false) + it('open existing template', async function () { + // When + await genWalkthroughProject('CustomTemplate', workspaceUri, undefined) + // Then nothing should be created + assert.ok(!(await fs.exists(vscode.Uri.joinPath(workspaceUri, 'template.yaml')))) + }) }) - it('build an app with appcomposer overwrite', async function () { - getTestWindow().onDidShowMessage((message) => { - message.selectItem('Yes') + describe('start with an existing template', async () => { + beforeEach(async () => { + await fs.writeFile(vscode.Uri.joinPath(workspaceUri, 'template.yaml'), prevInfo) }) - // When - await genWalkthroughProject('Visual', workspaceUri, undefined) - // Then - assert.notEqual(await fs.readFileText(vscode.Uri.joinPath(workspaceUri, 'template.yaml')), prevInfo) - }) - it('build an app with appcomposer no overwrite', async function () { - // Given - getTestWindow().onDidShowMessage((message) => { - message.selectItem('No') + afterEach(async function () { + await fs.delete(vscode.Uri.joinPath(workspaceUri, 'template.yaml'), { force: true }) }) - // When - try { - // When - await genWalkthroughProject('Visual', workspaceUri, undefined) - assert.fail('A file named template.yaml already exists in this path.') - } catch (e) { - assert.equal((e as Error).message, 'A file named template.yaml already exists in this path.') - } - // Then - assert.equal(await fs.readFileText(vscode.Uri.joinPath(workspaceUri, 'template.yaml')), prevInfo) - }) - it('download serverlessland proj', async function () { - // Given - // select overwrite - getTestWindow().onDidShowMessage((message) => { - message.selectItem('Yes') + describe('override existing template', async () => { + beforeEach(() => { + getTestWindow().onDidShowMessage((message) => { + assert.strictEqual( + message.message, + 'template.yaml already exist in the selected directory, overwrite?' + ) + message.selectItem('Yes') + }) + }) + + it('build an app with appcomposer', async function () { + // When + await genWalkthroughProject('Visual', workspaceUri, undefined) + // Then + assert.notEqual(await fs.readFileText(vscode.Uri.joinPath(workspaceUri, 'template.yaml')), prevInfo) + }) + + it('download serverlessland proj', async function () { + // When + await genWalkthroughProject('API', workspaceUri, 'python') + // Then template should be overwritten + assert.equal(await fs.exists(vscode.Uri.joinPath(workspaceUri, 'template.yaml')), true) + assert.notEqual(await fs.readFileText(vscode.Uri.joinPath(workspaceUri, 'template.yaml')), prevInfo) + }) }) - // When - await genWalkthroughProject('API', workspaceUri, 'python') - // Then template should be overwritten - assert.equal(await fs.exists(vscode.Uri.joinPath(workspaceUri, 'template.yaml')), true) - assert.notEqual(await fs.readFileText(vscode.Uri.joinPath(workspaceUri, 'template.yaml')), prevInfo) - }) - it('download serverlessland proj no overwrite', async function () { - // Given existing template.yaml - // select do not overwrite - getTestWindow().onDidShowMessage((message) => { - message.selectItem('No') + describe('without override existing template', async () => { + beforeEach(() => { + // Given existing template.yaml + // select do not overwrite + getTestWindow().onDidShowMessage((message) => { + assert.strictEqual( + message.message, + 'template.yaml already exist in the selected directory, overwrite?' + ) + message.selectItem('No') + }) + }) + + it('build an app with appcomposer', async function () { + // When + try { + // When + await genWalkthroughProject('Visual', workspaceUri, undefined) + assert.fail('Expect failure due to template.yaml already exists in this path, but see success.') + } catch (e) { + assert.equal((e as Error).message, 'A file named template.yaml already exists in this path.') + } + // Then + assert.equal(await fs.readFileText(vscode.Uri.joinPath(workspaceUri, 'template.yaml')), prevInfo) + }) + + it('download serverlessland proj', async function () { + try { + // When + await genWalkthroughProject('S3', workspaceUri, 'python') + assert.fail('Expect failure due to template.yaml already exists in this path, but see success.') + } catch (e) { + assert.equal((e as Error).message, 'A file named template.yaml already exists in this path.') + } + // Then no overwrite happens + assert.equal(await fs.readFileText(vscode.Uri.joinPath(workspaceUri, 'template.yaml')), prevInfo) + }) }) - try { - // When - await genWalkthroughProject('S3', workspaceUri, 'python') - assert.fail('A file named template.yaml already exists in this path.') - } catch (e) { - assert.equal((e as Error).message, 'A file named template.yaml already exists in this path.') - } - // Then no overwrite happens - assert.equal(await fs.readFileText(vscode.Uri.joinPath(workspaceUri, 'template.yaml')), prevInfo) }) }) diff --git a/packages/core/src/test/shared/sam/build.test.ts b/packages/core/src/test/shared/sam/build.test.ts index fb28d8528db..8043696d772 100644 --- a/packages/core/src/test/shared/sam/build.test.ts +++ b/packages/core/src/test/shared/sam/build.test.ts @@ -332,15 +332,16 @@ describe('SAM runBuild', () => { }) afterEach(() => { + spyRunInterminal.resetHistory() sandbox.restore() }) const verifyCorrectDependencyCall = () => { // Prefer count comparison for debugging flakiness - assert.strictEqual(mockGetSamCliPath.callCount, 1) - assert.strictEqual(mockChildProcessClass.callCount, 1) - assert.strictEqual(mockGetSpawnEnv.callCount, 1) - assert.strictEqual(spyRunInterminal.callCount, 1) + assert.strictEqual(1, mockGetSamCliPath.callCount, 'GetSamCliPath') + assert.strictEqual(1, mockChildProcessClass.callCount, 'ChildProcessClass') + assert.strictEqual(1, mockGetSpawnEnv.callCount, 'GetSpawnEnv') + assert.strictEqual(1, spyRunInterminal.callCount, 'RunInterminal') assert.deepEqual(spyRunInterminal.getCall(0).args, [mockSamBuildChildProcess, 'build']) } @@ -527,6 +528,7 @@ describe('SAM runBuild', () => { }, }, ]) + verifyCorrectDependencyCall() prompterTester.assertCallAll() }) From 0b2bccdc945b15d61ab5322205e3982ed8b7cc67 Mon Sep 17 00:00:00 2001 From: tsmithsz <84354541+tsmithsz@users.noreply.github.com> Date: Mon, 7 Apr 2025 07:50:18 -0700 Subject: [PATCH 14/17] feat(amazonq): more fields for agentic chat in chat history #6942 ## Problem Need to add additional fields to the chat history schema to support agentic chat ## Solution - Create new Message type to store all history fields - Add messageToChatMessage converter - Replace use of ChatItem type with Message type - Use messageToChatMessage converter to create triggerPayload history --- .../controllers/chat/chatRequest/converter.ts | 25 +------ .../controllers/chat/model.ts | 5 +- .../controllers/chat/tabBarController.ts | 6 +- packages/core/src/shared/db/chatDb/chatDb.ts | 13 ++-- packages/core/src/shared/db/chatDb/util.ts | 66 ++++++++++++++++++- 5 files changed, 80 insertions(+), 35 deletions(-) diff --git a/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts b/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts index 896d597f796..adc95f2ef69 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts @@ -3,18 +3,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - ConversationState, - CursorState, - DocumentSymbol, - SymbolType, - TextDocument, - ChatMessage, -} from '@amzn/codewhisperer-streaming' +import { ConversationState, CursorState, DocumentSymbol, SymbolType, TextDocument } from '@amzn/codewhisperer-streaming' import { AdditionalContentEntryAddition, ChatTriggerType, RelevantTextDocumentAddition, TriggerPayload } from '../model' import { undefinedIfEmpty } from '../../../../shared/utilities/textUtilities' -import { ChatItemType } from '../../../../amazonq/commons/model' import { getLogger } from '../../../../shared/logger/logger' +import { messageToChatMessage } from '../../../../shared/db/chatDb/util' const fqnNameSizeDownLimit = 1 const fqnNameSizeUpLimit = 256 @@ -158,19 +151,7 @@ export function triggerPayloadToChatRequest(triggerPayload: TriggerPayload): { c const history = triggerPayload.history && triggerPayload.history.length > 0 && - (triggerPayload.history.map((chat) => - chat.type === ('answer' as ChatItemType) - ? { - assistantResponseMessage: { - content: chat.body, - }, - } - : { - userInputMessage: { - content: chat.body, - }, - } - ) as ChatMessage[]) + triggerPayload.history.map((chat) => messageToChatMessage(chat)) return { conversationState: { diff --git a/packages/core/src/codewhispererChat/controllers/chat/model.ts b/packages/core/src/codewhispererChat/controllers/chat/model.ts index 5c41ba95111..2613a8bd8df 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/model.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/model.ts @@ -10,7 +10,8 @@ import { Selection } from 'vscode' import { TabOpenType } from '../../../amazonq/webview/ui/storages/tabsStorage' import { CodeReference } from '../../view/connector/connector' import { Customization } from '../../../codewhisperer/client/codewhispereruserclient' -import { ChatItem, QuickActionCommand } from '@aws/mynah-ui' +import { QuickActionCommand } from '@aws/mynah-ui' +import { Message } from '../../../shared/db/chatDb/util' export interface TriggerTabIDReceived { tabID: string @@ -206,7 +207,7 @@ export interface TriggerPayload { traceId?: string contextLengths: ContextLengths workspaceRulesCount?: number - history?: ChatItem[] + history?: Message[] } export type ContextLengths = { diff --git a/packages/core/src/codewhispererChat/controllers/chat/tabBarController.ts b/packages/core/src/codewhispererChat/controllers/chat/tabBarController.ts index ce0970040a2..13088e096f8 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/tabBarController.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/tabBarController.ts @@ -13,7 +13,7 @@ import * as vscode from 'vscode' import { Messenger } from './messenger/messenger' import { Database } from '../../../shared/db/chatDb/chatDb' import { TabBarButtonClick, SaveChatMessage } from './model' -import { Conversation, Tab } from '../../../shared/db/chatDb/util' +import { Conversation, messageToChatItem, Tab } from '../../../shared/db/chatDb/util' import { DetailedListItemGroup, MynahIconsType } from '@aws/mynah-ui' export class TabBarController { @@ -87,7 +87,9 @@ export class TabBarController { this.messenger.sendRestoreTabMessage( selectedTab.historyId, selectedTab.tabType, - selectedTab.conversations.flatMap((conv: Conversation) => conv.messages), + selectedTab.conversations.flatMap((conv: Conversation) => + conv.messages.map((message) => messageToChatItem(message)) + ), exportTab ) } diff --git a/packages/core/src/shared/db/chatDb/chatDb.ts b/packages/core/src/shared/db/chatDb/chatDb.ts index 43c594dc376..df936601799 100644 --- a/packages/core/src/shared/db/chatDb/chatDb.ts +++ b/packages/core/src/shared/db/chatDb/chatDb.ts @@ -5,12 +5,13 @@ import Loki from 'lokijs' import * as vscode from 'vscode' import { TabType } from '../../../amazonq/webview/ui/storages/tabsStorage' -import { ChatItem, ChatItemType, DetailedListItemGroup } from '@aws/mynah-ui' +import { ChatItemType, DetailedListItemGroup } from '@aws/mynah-ui' import { ClientType, Conversation, FileSystemAdapter, groupTabsByDate, + Message, Tab, TabCollection, updateOrCreateConversation, @@ -171,7 +172,7 @@ export class Database { const tabs = tabCollection.find() const filteredTabs = tabs.filter((tab: Tab) => { return tab.conversations.some((conversation: Conversation) => { - return conversation.messages.some((message: ChatItem) => { + return conversation.messages.some((message: Message) => { return message.body?.toLowerCase().includes(searchTermLower) }) }) @@ -225,7 +226,7 @@ export class Database { } } - addMessage(tabId: string, tabType: TabType, conversationId: string, chatItem: ChatItem) { + addMessage(tabId: string, tabType: TabType, conversationId: string, message: Message) { if (this.initialized) { const tabCollection = this.db.getCollection(TabCollection) @@ -238,9 +239,9 @@ export class Database { const tabData = historyId ? tabCollection.findOne({ historyId }) : undefined const tabTitle = - (chatItem.type === ('prompt' as ChatItemType) ? chatItem.body : tabData?.title) || 'Amazon Q Chat' + (message.type === ('prompt' as ChatItemType) ? message.body : tabData?.title) || 'Amazon Q Chat' if (tabData) { - tabData.conversations = updateOrCreateConversation(tabData.conversations, conversationId, chatItem) + tabData.conversations = updateOrCreateConversation(tabData.conversations, conversationId, message) tabData.updatedAt = new Date() tabData.title = tabTitle tabCollection.update(tabData) @@ -251,7 +252,7 @@ export class Database { isOpen: true, tabType: tabType, title: tabTitle, - conversations: [{ conversationId, clientType: ClientType.VSCode, messages: [chatItem] }], + conversations: [{ conversationId, clientType: ClientType.VSCode, messages: [message] }], }) } } diff --git a/packages/core/src/shared/db/chatDb/util.ts b/packages/core/src/shared/db/chatDb/util.ts index 5c260be60b5..09ba4090b9c 100644 --- a/packages/core/src/shared/db/chatDb/util.ts +++ b/packages/core/src/shared/db/chatDb/util.ts @@ -6,7 +6,17 @@ import fs from '../../fs/fs' import path from 'path' import { TabType } from '../../../amazonq/webview/ui/storages/tabsStorage' -import { ChatItem, ChatItemButton, DetailedListItem, DetailedListItemGroup, MynahIconsType } from '@aws/mynah-ui' +import { + ChatItem, + ChatItemButton, + ChatItemType, + DetailedListItem, + DetailedListItemGroup, + MynahIconsType, + ReferenceTrackerInformation, + SourceLink, +} from '@aws/mynah-ui' +import { ChatMessage, Origin, ToolUse, UserInputMessageContext, UserIntent } from '@amzn/codewhisperer-streaming' export const TabCollection = 'tabs' @@ -24,7 +34,7 @@ export type Tab = { export type Conversation = { conversationId: string clientType: ClientType - messages: ChatItem[] + messages: Message[] } export enum ClientType { @@ -33,6 +43,56 @@ export enum ClientType { CLI = 'CLI', } +export type Message = { + body: string + type: ChatItemType + codeReference?: ReferenceTrackerInformation[] + relatedContent?: { + title?: string + content: SourceLink[] + } + messageId?: string + userIntent?: UserIntent + origin?: Origin + userInputMessageContext?: UserInputMessageContext + toolUses?: ToolUse[] +} + +/** + * Converts Message to CodeWhisperer Streaming ChatMessage + */ +export function messageToChatMessage(msg: Message): ChatMessage { + return msg.type === ('answer' as ChatItemType) + ? { + assistantResponseMessage: { + messageId: msg.messageId, + content: msg.body, + references: msg.codeReference || [], + toolUses: msg.toolUses || [], + }, + } + : { + userInputMessage: { + content: msg.body, + userIntent: msg.userIntent, + origin: msg.origin || 'IDE', + userInputMessageContext: msg.userInputMessageContext || {}, + }, + } +} + +/** + * Converts Message to MynahUI Chat Item + */ +export function messageToChatItem(msg: Message): ChatItem { + return { + body: msg.body, + type: msg.type as ChatItemType, + codeReference: msg.codeReference, + relatedContent: msg.relatedContent && msg.relatedContent?.content.length > 0 ? msg.relatedContent : undefined, + } +} + /** * * This adapter implements the LokiPersistenceAdapter interface for file system operations using web-compatible shared fs utils. @@ -107,7 +167,7 @@ export class FileSystemAdapter implements LokiPersistenceAdapter { export function updateOrCreateConversation( conversations: Conversation[], conversationId: string, - newMessage: ChatItem + newMessage: Message ): Conversation[] { const existingConversation = conversations.find((conv) => conv.conversationId === conversationId) From a24de712d14defed1f2d35169545c55e26c5f8a8 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Thu, 3 Apr 2025 12:10:54 -0700 Subject: [PATCH 15/17] test(ec2): unreliable test "SSH Agent can start the agent on windows" Problem: SSH Agent can start the agent on windows: Error: Test length exceeded max duration: 30 seconds [No Pending UI Elements Found] at Timeout._onTimeout (D:\a\aws-toolkit-vscode\aws-toolkit-vscode\packages\core\src\test\setupUtil.ts:46:32) Solution: - retry 2 times. --- .../core/src/test/shared/extensions/ssh.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/core/src/test/shared/extensions/ssh.test.ts b/packages/core/src/test/shared/extensions/ssh.test.ts index 38874e2df68..e7a012f182d 100644 --- a/packages/core/src/test/shared/extensions/ssh.test.ts +++ b/packages/core/src/test/shared/extensions/ssh.test.ts @@ -15,19 +15,22 @@ import { isWin } from '../../../shared/vscode/env' describe('SSH Agent', function () { it('can start the agent on windows', async function () { + this.retries(2) + // TODO: we should also skip this test if not running in CI // Local machines probably won't have admin permissions in the spawned processes if (process.platform !== 'win32') { this.skip() } - const runCommand = (command: string) => { - const args = ['-Command', command] - return new ChildProcess('powershell.exe', args).run({ rejectOnErrorCode: true }) + async function runCommand(command: string) { + const args = ['-NoLogo', '-NonInteractive', '-ExecutionPolicy', 'RemoteSigned', '-Command', command] + return await new ChildProcess('pwsh.exe', args).run({ rejectOnErrorCode: true }) } - const getStatus = () => { - return runCommand('echo (Get-Service ssh-agent).Status').then((o) => o.stdout) + async function getStatus() { + const c = await runCommand('echo (Get-Service ssh-agent).Status') + return c.stdout } await runCommand('Stop-Service ssh-agent') From 5c56251be98efa17a03f78ddcbf51018b4fb5b84 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Mon, 7 Apr 2025 08:49:42 -0700 Subject: [PATCH 16/17] fix(childprocess): log rejected command Problem: Unhandled promise rejection results in mysterious CI log message: rejected promise not handled within 1 second: Error: Command exited with non-zero code: 1 Solution: Include the command name+args in the rejection message. --- packages/core/src/shared/utilities/processUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/shared/utilities/processUtils.ts b/packages/core/src/shared/utilities/processUtils.ts index c851cefb376..25af4418e1e 100644 --- a/packages/core/src/shared/utilities/processUtils.ts +++ b/packages/core/src/shared/utilities/processUtils.ts @@ -367,7 +367,7 @@ export class ChildProcess { if (typeof rejectOnErrorCode === 'function') { reject(rejectOnErrorCode(code)) } else { - reject(new Error(`Command exited with non-zero code: ${code}`)) + reject(new Error(`Command exited with non-zero code (${code}): ${this.toString()}`)) } } if (options.waitForStreams === false) { From 738d8183326a8da76206849dbc34620f6dcbfb7e Mon Sep 17 00:00:00 2001 From: Tyrone Smith Date: Mon, 7 Apr 2025 11:14:21 -0700 Subject: [PATCH 17/17] Fix: Fix failing lint check --- .../codewhispererChat/controllers/chat/messenger/messenger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts index 95060a61be7..4a23ecf868a 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts @@ -587,7 +587,7 @@ export class Messenger { codeBlockActions: // eslint-disable-next-line unicorn/no-null, prettier/prettier toolUse?.name === ToolType.FsWrite || toolUse?.name === ToolType.ExecuteBash - ? { 'insert-to-cursor': null, copy: null } + ? { 'insert-to-cursor': undefined, copy: undefined } : undefined, }, tabID