From 562128ac63630da319f173e4155d0818f43481b4 Mon Sep 17 00:00:00 2001 From: Lei Gao <97199248+leigaol@users.noreply.github.com> Date: Thu, 10 Oct 2024 08:07:26 -0700 Subject: [PATCH 1/3] feat(amazonq): Decouple LSP from vector index creation; start LSP by default (#5702) ## Problem Current LSP start is coupled with vector index creation, this should be decoupled. We will be having new releases in the LSP to build some other indexes for everyone very soon ( not computation expensive as vector index), therefore we need to start LSP by default. The vector indexing, as a computational expensive index, will be only enabled if opt-in. ## Solution Decouple LSP from vector index creation; start LSP by default --- License: I confirm that my contribution is made under the terms of the Apache 2.0 license. --- ...x-5576420e-6db2-4045-a99f-afced496da0f.json | 4 ++++ packages/core/src/amazonq/lsp/lspController.ts | 18 +++++++----------- 2 files changed, 11 insertions(+), 11 deletions(-) create mode 100644 packages/amazonq/.changes/next-release/Bug Fix-5576420e-6db2-4045-a99f-afced496da0f.json diff --git a/packages/amazonq/.changes/next-release/Bug Fix-5576420e-6db2-4045-a99f-afced496da0f.json b/packages/amazonq/.changes/next-release/Bug Fix-5576420e-6db2-4045-a99f-afced496da0f.json new file mode 100644 index 00000000000..9a9d4945af8 --- /dev/null +++ b/packages/amazonq/.changes/next-release/Bug Fix-5576420e-6db2-4045-a99f-afced496da0f.json @@ -0,0 +1,4 @@ +{ + "type": "Bug Fix", + "description": "Start language server by default" +} diff --git a/packages/core/src/amazonq/lsp/lspController.ts b/packages/core/src/amazonq/lsp/lspController.ts index bd96bceaa3c..f9a20307e8a 100644 --- a/packages/core/src/amazonq/lsp/lspController.ts +++ b/packages/core/src/amazonq/lsp/lspController.ts @@ -304,7 +304,7 @@ export class LspController { } async buildIndex() { - getLogger().info(`LspController: Starting to build vector index of project`) + getLogger().info(`LspController: Starting to build index of project`) const start = performance.now() const projPaths = getProjectPaths() projPaths.sort() @@ -331,7 +331,7 @@ export class LspController { false ) if (resp) { - getLogger().debug(`LspController: Finish building vector index of project`) + getLogger().debug(`LspController: Finish building index of project`) const usage = await LspClient.instance.getLspServerUsage() telemetry.amazonq_indexWorkspace.emit({ duration: performance.now() - start, @@ -343,7 +343,7 @@ export class LspController { credentialStartUrl: AuthUtil.instance.startUrl, }) } else { - getLogger().error(`LspController: Failed to build vector index of project`) + getLogger().error(`LspController: Failed to build index of project`) telemetry.amazonq_indexWorkspace.emit({ duration: performance.now() - start, result: 'Failed', @@ -352,7 +352,7 @@ export class LspController { }) } } catch (e) { - getLogger().error(`LspController: Failed to build vector index of project`) + getLogger().error(`LspController: Failed to build index of project`) telemetry.amazonq_indexWorkspace.emit({ duration: performance.now() - start, result: 'Failed', @@ -371,12 +371,6 @@ export class LspController { return } setImmediate(async () => { - if (!CodeWhispererSettings.instance.isLocalIndexEnabled()) { - // only download LSP for users who did not turn on this feature - // do not start LSP server - await LspController.instance.tryInstallLsp(context) - return - } const ok = await LspController.instance.tryInstallLsp(context) if (!ok) { return @@ -384,7 +378,9 @@ export class LspController { try { await activateLsp(context) getLogger().info('LspController: LSP activated') - void LspController.instance.buildIndex() + if (CodeWhispererSettings.instance.isLocalIndexEnabled()) { + void LspController.instance.buildIndex() + } // log the LSP server CPU and Memory usage per 30 minutes. globals.clock.setInterval( async () => { From 62e9cfb6c29dd377835540c68354b438d0a4deac Mon Sep 17 00:00:00 2001 From: Vikash Agrawal Date: Thu, 10 Oct 2024 10:20:20 -0700 Subject: [PATCH 2/3] feat(amazonq): add button to view diff in IDE (#5338) Add `Accept Diff` & `View Diff` button to Amazon Q for auto generated code. https://github.com/user-attachments/assets/2bd6cf19-0649-4355-8ac5-7eb87bfe27fb ## License By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. --- ...-3ebcda93-ca02-4682-b475-dead26e94900.json | 4 + packages/amazonq/src/extension.ts | 11 ++ .../test/e2e/amazonq/featureDev.test.ts | 4 +- .../test/e2e/amazonq/framework/framework.ts | 6 +- .../commons/controllers/contentController.ts | 122 ++++++++++++++++++ packages/core/src/amazonq/index.ts | 9 +- .../core/src/amazonq/util/functionUtils.ts | 24 ++++ .../webview/generators/webViewContent.ts | 13 +- .../webview/ui/apps/cwChatConnector.ts | 24 ++-- .../ui/apps/featureDevChatConnector.ts | 40 +++--- .../webview/ui/apps/gumbyChatConnector.ts | 18 ++- .../core/src/amazonq/webview/ui/commands.ts | 2 + .../core/src/amazonq/webview/ui/connector.ts | 72 ++++++++++- packages/core/src/amazonq/webview/ui/main.ts | 119 ++++++++++++++++- .../amazonq/webview/ui/messages/controller.ts | 8 +- .../amazonq/webview/ui/messages/handler.ts | 1 + .../webview/ui/storages/tabsStorage.ts | 13 ++ packages/core/src/codewhispererChat/app.ts | 8 ++ .../controllers/chat/controller.ts | 38 ++++++ .../controllers/chat/model.ts | 25 ++++ .../controllers/chat/telemetryHelper.ts | 51 +++++++- .../view/messages/messageListener.ts | 22 ++++ packages/core/src/shared/constants.ts | 3 + packages/core/src/shared/errors.ts | 19 ++- packages/core/src/shared/index.ts | 2 + .../src/shared/telemetry/vscodeTelemetry.json | 4 +- .../src/shared/utilities/editorUtilities.ts | 18 +++ .../shared/utilities/textDocumentUtilities.ts | 89 +++++++++++++ .../src/shared/utilities/textUtilities.ts | 49 ++++++- 29 files changed, 754 insertions(+), 64 deletions(-) create mode 100644 packages/amazonq/.changes/next-release/Feature-3ebcda93-ca02-4682-b475-dead26e94900.json create mode 100644 packages/core/src/amazonq/util/functionUtils.ts diff --git a/packages/amazonq/.changes/next-release/Feature-3ebcda93-ca02-4682-b475-dead26e94900.json b/packages/amazonq/.changes/next-release/Feature-3ebcda93-ca02-4682-b475-dead26e94900.json new file mode 100644 index 00000000000..124bbc0f4bb --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-3ebcda93-ca02-4682-b475-dead26e94900.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "Add buttons to code blocks to view and accept diffs." +} diff --git a/packages/amazonq/src/extension.ts b/packages/amazonq/src/extension.ts index bc5c8ae34c8..d7c7f3c1448 100644 --- a/packages/amazonq/src/extension.ts +++ b/packages/amazonq/src/extension.ts @@ -20,11 +20,14 @@ import { import { makeEndpointsProvider, registerGenericCommands } from 'aws-core-vscode' import { CommonAuthWebview } from 'aws-core-vscode/login' import { + amazonQDiffScheme, DefaultAWSClientBuilder, DefaultAwsContext, ExtContext, RegionProvider, Settings, + VirtualFileSystem, + VirtualMemoryFile, activateLogger, activateTelemetry, env, @@ -136,6 +139,14 @@ export async function activateAmazonQCommon(context: vscode.ExtensionContext, is // Handle Amazon Q Extension un-installation. setupUninstallHandler(VSCODE_EXTENSION_ID.amazonq, context.extension.packageJSON.version, context) + const vfs = new VirtualFileSystem() + + // Register an empty file that's used when a to open a diff + vfs.registerProvider( + vscode.Uri.from({ scheme: amazonQDiffScheme, path: 'empty' }), + new VirtualMemoryFile(new Uint8Array()) + ) + // Hide the Amazon Q tree in toolkit explorer await setContext('aws.toolkit.amazonq.dismissed', true) diff --git a/packages/amazonq/test/e2e/amazonq/featureDev.test.ts b/packages/amazonq/test/e2e/amazonq/featureDev.test.ts index 861ce35fb2b..9cd87003f36 100644 --- a/packages/amazonq/test/e2e/amazonq/featureDev.test.ts +++ b/packages/amazonq/test/e2e/amazonq/featureDev.test.ts @@ -110,7 +110,7 @@ describe('Amazon Q Feature Dev', function () { beforeEach(() => { registerAuthHook('amazonq-test-account') - framework = new qTestingFramework('featuredev', true) + framework = new qTestingFramework('featuredev', true, []) tab = framework.createTab() }) @@ -135,7 +135,7 @@ describe('Amazon Q Feature Dev', function () { it('Does NOT show /dev when feature dev is NOT enabled', () => { // The beforeEach registers a framework which accepts requests. If we don't dispose before building a new one we have duplicate messages framework.dispose() - framework = new qTestingFramework('featuredev', false) + framework = new qTestingFramework('featuredev', false, []) const tab = framework.createTab() const command = tab.findCommand('/dev') if (command.length > 0) { diff --git a/packages/amazonq/test/e2e/amazonq/framework/framework.ts b/packages/amazonq/test/e2e/amazonq/framework/framework.ts index 8c9e89c49f8..b65e8b184f7 100644 --- a/packages/amazonq/test/e2e/amazonq/framework/framework.ts +++ b/packages/amazonq/test/e2e/amazonq/framework/framework.ts @@ -12,6 +12,7 @@ import * as vscode from 'vscode' import { MynahUI, MynahUIProps } from '@aws/mynah-ui' import { DefaultAmazonQAppInitContext, TabType, createMynahUI } from 'aws-core-vscode/amazonq' import { Messenger, MessengerOptions } from './messenger' +import { FeatureContext } from 'aws-core-vscode/shared' /** * Abstraction over Amazon Q to make e2e testing easier @@ -23,7 +24,7 @@ export class qTestingFramework { lastEventId: string = '' - constructor(featureName: TabType, amazonQEnabled: boolean) { + constructor(featureName: TabType, amazonQEnabled: boolean, featureConfigsSerialized: [string, FeatureContext][]) { /** * Instantiate the UI and override the postMessage to publish using the app message * publishers directly. @@ -42,7 +43,8 @@ export class qTestingFramework { appMessagePublisher.publish(message) }, }, - amazonQEnabled + amazonQEnabled, + featureConfigsSerialized ) this.mynahUI = ui.mynahUI this.mynahUIProps = (this.mynahUI as any).props diff --git a/packages/core/src/amazonq/commons/controllers/contentController.ts b/packages/core/src/amazonq/commons/controllers/contentController.ts index d77ef176ec1..edcb84f9fb0 100644 --- a/packages/core/src/amazonq/commons/controllers/contentController.ts +++ b/packages/core/src/amazonq/commons/controllers/contentController.ts @@ -4,8 +4,28 @@ */ import * as vscode from 'vscode' +import path from 'path' import { Position, TextEditor, window } from 'vscode' import { getLogger } from '../../../shared/logger' +import { amazonQDiffScheme, amazonQTabSuffix } from '../../../shared/constants' +import { disposeOnEditorClose } from '../../../shared/utilities/editorUtilities' +import { + applyChanges, + createTempFileForDiff, + getSelectionFromRange, +} from '../../../shared/utilities/textDocumentUtilities' +import { extractFileAndCodeSelectionFromMessage, fs, getErrorMsg, getIndentedCode, ToolkitError } from '../../../shared' + +class ContentProvider implements vscode.TextDocumentContentProvider { + constructor(private uri: vscode.Uri) {} + + provideTextDocumentContent(_uri: vscode.Uri) { + return fs.readFileText(this.uri.fsPath) + } +} + +const chatDiffCode = 'ChatDiff' +const ChatDiffError = ToolkitError.named(chatDiffCode) export class EditorContentController { /* * @@ -52,4 +72,106 @@ export class EditorContentController { ) } } + + /** + * Accepts and applies a diff to a file, then closes the associated diff view tab. + * + * @param {any} message - The message containing diff information. + * @returns {Promise} A promise that resolves when the diff is applied and the tab is closed. + * + * @description + * This method performs the following steps: + * 1. Extracts file path and selection from the message. + * 2. If valid file path, non-empty code, and selection are present: + * a. Opens the document. + * b. Gets the indented code to update. + * c. Applies the changes to the document. + * d. Attempts to close the diff view tab for the file. + * + * @throws {Error} If there's an issue opening the document or applying changes. + */ + public async acceptDiff(message: any) { + const errorNotification = 'Unable to Apply code changes.' + const { filePath, selection } = extractFileAndCodeSelectionFromMessage(message) + + if (filePath && message?.code?.trim().length > 0 && selection) { + try { + const doc = await vscode.workspace.openTextDocument(filePath) + + const code = getIndentedCode(message, doc, selection) + const range = getSelectionFromRange(doc, selection) + await applyChanges(doc, range, code) + + // Sets the editor selection from the start of the given range, extending it by the number of lines in the code till the end of the last line + const editor = await vscode.window.showTextDocument(doc) + editor.selection = new vscode.Selection( + range.start, + new Position(range.start.line + code.split('\n').length, Number.MAX_SAFE_INTEGER) + ) + + // If vscode.diff is open for the filePath then close it. + vscode.window.tabGroups.all.flatMap(({ tabs }) => + tabs.map((tab) => { + if (tab.label === `${path.basename(filePath)} ${amazonQTabSuffix}`) { + const tabClosed = vscode.window.tabGroups.close(tab) + if (!tabClosed) { + getLogger().error( + '%s: Unable to close the diff view tab for %s', + chatDiffCode, + tab.label + ) + } + } + }) + ) + } catch (error) { + void vscode.window.showInformationMessage(errorNotification) + const wrappedError = ChatDiffError.chain(error, `Failed to Accept Diff`, { code: chatDiffCode }) + getLogger().error('%s: Failed to open diff view %s', chatDiffCode, getErrorMsg(wrappedError, true)) + throw wrappedError + } + } + } + + /** + * Displays a diff view comparing proposed changes with the existing file. + * + * How is diff generated: + * 1. Creates a temporary file as a clone of the original file. + * 2. Applies the proposed changes to the temporary file within the selected range. + * 3. Opens a diff view comparing original file to the temporary file. + * + * This approach ensures that the diff view only shows the changes proposed by Amazon Q, + * isolating them from any other modifications in the original file. + * + * @param message the message from Amazon Q chat + */ + public async viewDiff(message: any, scheme: string = amazonQDiffScheme) { + const errorNotification = 'Unable to Open Diff.' + const { filePath, selection } = extractFileAndCodeSelectionFromMessage(message) + + try { + if (filePath && message?.code?.trim().length > 0 && selection) { + const originalFileUri = vscode.Uri.file(filePath) + const uri = await createTempFileForDiff(originalFileUri, message, selection, scheme) + + // Register content provider and show diff + const contentProvider = new ContentProvider(uri) + const disposable = vscode.workspace.registerTextDocumentContentProvider(scheme, contentProvider) + await vscode.commands.executeCommand( + 'vscode.diff', + originalFileUri, + uri, + `${path.basename(filePath)} ${amazonQTabSuffix}` + ) + + disposeOnEditorClose(uri, disposable) + } + } catch (error) { + void vscode.window.showInformationMessage(errorNotification) + const wrappedError = ChatDiffError.chain(error, `Failed to Open Diff View`, { code: chatDiffCode }) + getLogger().error('%s: Failed to open diff view %s', chatDiffCode, getErrorMsg(wrappedError, true)) + throw wrappedError + } + } } diff --git a/packages/core/src/amazonq/index.ts b/packages/core/src/amazonq/index.ts index ec7dc9a3bf8..2900cd921ef 100644 --- a/packages/core/src/amazonq/index.ts +++ b/packages/core/src/amazonq/index.ts @@ -28,6 +28,7 @@ export { listCodeWhispererCommandsWalkthrough } from '../codewhisperer/ui/status export { focusAmazonQPanel, focusAmazonQPanelKeybinding } from '../codewhispererChat/commands/registerCommands' export { TryChatCodeLensProvider, tryChatCodeLensCommand } from '../codewhispererChat/editor/codelens' export { createAmazonQUri, openDiff, openDeletedDiff, getOriginalFileUri, getFileDiffUris } from './commons/diff' +import { FeatureContext } from '../shared' /** * main from createMynahUI is a purely browser dependency. Due to this @@ -35,10 +36,14 @@ export { createAmazonQUri, openDiff, openDeletedDiff, getOriginalFileUri, getFil * while only running on browser instances (like the e2e tests). If we * just export it regularly we will get "ReferenceError: self is not defined" */ -export function createMynahUI(ideApi: any, amazonQEnabled: boolean) { +export function createMynahUI( + ideApi: any, + amazonQEnabled: boolean, + featureConfigsSerialized: [string, FeatureContext][] +) { if (typeof window !== 'undefined') { const mynahUI = require('./webview/ui/main') - return mynahUI.createMynahUI(ideApi, amazonQEnabled) + return mynahUI.createMynahUI(ideApi, amazonQEnabled, featureConfigsSerialized) } throw new Error('Not implemented for node') } diff --git a/packages/core/src/amazonq/util/functionUtils.ts b/packages/core/src/amazonq/util/functionUtils.ts new file mode 100644 index 00000000000..c658b59aeef --- /dev/null +++ b/packages/core/src/amazonq/util/functionUtils.ts @@ -0,0 +1,24 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Converts an array of key-value pairs into a Map object. + * + * @param {[unknown, unknown][]} arr - An array of tuples, where each tuple represents a key-value pair. + * @returns {Map} A new Map object created from the input array. + * If the conversion fails, an empty Map is returned. + * + * @example + * const array = [['key1', 'value1'], ['key2', 'value2']]; + * const map = tryNewMap(array); + * // map is now a Map object with entries: { 'key1' => 'value1', 'key2' => 'value2' } + */ +export function tryNewMap(arr: [unknown, unknown][]) { + try { + return new Map(arr) + } catch (error) { + return new Map() + } +} diff --git a/packages/core/src/amazonq/webview/generators/webViewContent.ts b/packages/core/src/amazonq/webview/generators/webViewContent.ts index 47cb59a9f2e..494938b5911 100644 --- a/packages/core/src/amazonq/webview/generators/webViewContent.ts +++ b/packages/core/src/amazonq/webview/generators/webViewContent.ts @@ -6,7 +6,7 @@ import path from 'path' import { Uri, Webview } from 'vscode' import { AuthUtil } from '../../../codewhisperer/util/authUtil' -import { globals } from '../../../shared' +import { FeatureConfigProvider, FeatureContext, globals } from '../../../shared' export class WebViewContentGenerator { public async generate(extensionURI: Uri, webView: Webview): Promise { @@ -45,6 +45,15 @@ export class WebViewContentGenerator { Uri.joinPath(globals.context.extensionUri, 'resources', 'css', 'amazonq-webview.css') ) + let featureConfigs = new Map() + try { + await FeatureConfigProvider.instance.fetchFeatureConfigs() + featureConfigs = FeatureConfigProvider.getFeatureConfigs() + } catch (error) { + // eslint-disable-next-line aws-toolkits/no-console-log + console.error('Error fetching feature configs:', error) + } + return ` @@ -52,7 +61,7 @@ export class WebViewContentGenerator { const init = () => { createMynahUI(acquireVsCodeApi(), ${ (await AuthUtil.instance.getChatAuthState()).amazonQ === 'connected' - }); + },${JSON.stringify(Array.from(featureConfigs.entries()))}); } ` diff --git a/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts b/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts index 0d48a127d81..09963fb77ab 100644 --- a/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts @@ -18,7 +18,7 @@ interface ChatPayload { export interface ConnectorProps { sendMessageToExtension: (message: ExtensionMessage) => void onMessageReceived?: (tabID: string, messageData: any, needToShowAPIDocsTab: boolean) => void - onChatAnswerReceived?: (tabID: string, message: CWCChatItem) => void + onChatAnswerReceived?: (tabID: string, message: CWCChatItem, messageData: any) => void onCWCContextCommandMessage: (message: CWCChatItem, command?: string) => string | undefined onError: (tabID: string, message: string, title: string) => void onWarning: (tabID: string, message: string, title: string) => void @@ -308,7 +308,7 @@ export class Connector { content: messageData.relatedSuggestions, } } - this.onChatAnswerReceived(messageData.tabID, answer) + this.onChatAnswerReceived(messageData.tabID, answer, messageData) // Exit the function if we received an answer from AI if ( @@ -336,7 +336,7 @@ export class Connector { } : undefined, } - this.onChatAnswerReceived(messageData.tabID, answer) + this.onChatAnswerReceived(messageData.tabID, answer, messageData) return } @@ -347,13 +347,17 @@ export class Connector { return } - this.onChatAnswerReceived(messageData.tabID, { - type: ChatItemType.ANSWER, - messageId: messageData.triggerID, - body: messageData.message, - followUp: this.followUpGenerator.generateAuthFollowUps('cwc', messageData.authType), - canBeVoted: false, - }) + this.onChatAnswerReceived( + messageData.tabID, + { + type: ChatItemType.ANSWER, + messageId: messageData.triggerID, + body: messageData.message, + followUp: this.followUpGenerator.generateAuthFollowUps('cwc', messageData.authType), + canBeVoted: false, + }, + messageData + ) return } diff --git a/packages/core/src/amazonq/webview/ui/apps/featureDevChatConnector.ts b/packages/core/src/amazonq/webview/ui/apps/featureDevChatConnector.ts index 4a31fe337c4..3e013566c91 100644 --- a/packages/core/src/amazonq/webview/ui/apps/featureDevChatConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/featureDevChatConnector.ts @@ -19,7 +19,7 @@ export interface ConnectorProps { sendMessageToExtension: (message: ExtensionMessage) => void onMessageReceived?: (tabID: string, messageData: any, needToShowAPIDocsTab: boolean) => void onAsyncEventProgress: (tabID: string, inProgress: boolean, message: string) => void - onChatAnswerReceived?: (tabID: string, message: ChatItem) => void + onChatAnswerReceived?: (tabID: string, message: ChatItem, messageData: any) => void sendFeedback?: (tabId: string, feedbackPayload: FeedbackPayload) => void | undefined onError: (tabID: string, message: string, title: string) => void onWarning: (tabID: string, message: string, title: string) => void @@ -154,7 +154,7 @@ export class Connector { } : undefined, } - this.onChatAnswerReceived(messageData.tabID, answer) + this.onChatAnswerReceived(messageData.tabID, answer, messageData) } } @@ -181,7 +181,7 @@ export class Connector { }, body: '', } - this.onChatAnswerReceived(messageData.tabID, answer) + this.onChatAnswerReceived(messageData.tabID, answer, messageData) } } @@ -190,19 +190,27 @@ export class Connector { return } - this.onChatAnswerReceived(messageData.tabID, { - type: ChatItemType.ANSWER, - body: messageData.message, - followUp: undefined, - canBeVoted: false, - }) - - this.onChatAnswerReceived(messageData.tabID, { - type: ChatItemType.SYSTEM_PROMPT, - body: undefined, - followUp: this.followUpGenerator.generateAuthFollowUps('featuredev', messageData.authType), - canBeVoted: false, - }) + this.onChatAnswerReceived( + messageData.tabID, + { + type: ChatItemType.ANSWER, + body: messageData.message, + followUp: undefined, + canBeVoted: false, + }, + messageData + ) + + this.onChatAnswerReceived( + messageData.tabID, + { + type: ChatItemType.SYSTEM_PROMPT, + body: undefined, + followUp: this.followUpGenerator.generateAuthFollowUps('featuredev', messageData.authType), + canBeVoted: false, + }, + messageData + ) return } diff --git a/packages/core/src/amazonq/webview/ui/apps/gumbyChatConnector.ts b/packages/core/src/amazonq/webview/ui/apps/gumbyChatConnector.ts index 533a20da332..b12df0a6ef1 100644 --- a/packages/core/src/amazonq/webview/ui/apps/gumbyChatConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/gumbyChatConnector.ts @@ -17,7 +17,7 @@ export interface ConnectorProps { sendMessageToExtension: (message: ExtensionMessage) => void onMessageReceived?: (tabID: string, messageData: any, needToShowAPIDocsTab: boolean) => void onAsyncEventProgress: (tabID: string, inProgress: boolean, message: string, messageId: string) => void - onChatAnswerReceived?: (tabID: string, message: ChatItem) => void + onChatAnswerReceived?: (tabID: string, message: ChatItem, messageData: any) => void onChatAnswerUpdated?: (tabID: string, message: ChatItem) => void onQuickHandlerCommand: (tabID: string, command: string, eventId?: string) => void onError: (tabID: string, message: string, title: string) => void @@ -90,7 +90,7 @@ export class Connector { canBeVoted: false, } - this.onChatAnswerReceived(tabID, answer) + this.onChatAnswerReceived(tabID, answer, messageData) return } @@ -114,7 +114,7 @@ export class Connector { return } - this.onChatAnswerReceived(messageData.tabID, answer) + this.onChatAnswerReceived(messageData.tabID, answer, messageData) } } @@ -143,10 +143,14 @@ export class Connector { return } - this.onChatAnswerReceived(messageData.tabID, { - type: ChatItemType.SYSTEM_PROMPT, - body: messageData.message, - }) + this.onChatAnswerReceived( + messageData.tabID, + { + type: ChatItemType.SYSTEM_PROMPT, + body: messageData.message, + }, + messageData + ) } onCustomFormAction( diff --git a/packages/core/src/amazonq/webview/ui/commands.ts b/packages/core/src/amazonq/webview/ui/commands.ts index 6b803611d63..d502cba861d 100644 --- a/packages/core/src/amazonq/webview/ui/commands.ts +++ b/packages/core/src/amazonq/webview/ui/commands.ts @@ -16,6 +16,8 @@ type MessageCommand = | 'open-diff' | 'code_was_copied_to_clipboard' | 'insert_code_at_cursor_position' + | 'accept_diff' + | 'view_diff' | 'stop-response' | 'trigger-tabID-received' | 'clear' diff --git a/packages/core/src/amazonq/webview/ui/connector.ts b/packages/core/src/amazonq/webview/ui/connector.ts index 05970ce3e94..3d743c04f31 100644 --- a/packages/core/src/amazonq/webview/ui/connector.ts +++ b/packages/core/src/amazonq/webview/ui/connector.ts @@ -3,7 +3,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ChatItem, FeedbackPayload, Engagement, ChatItemAction } from '@aws/mynah-ui' +import { + ChatItem, + FeedbackPayload, + Engagement, + ChatItemAction, + CodeSelectionType, + ReferenceTrackerInformation, +} from '@aws/mynah-ui' import { Connector as CWChatConnector } from './apps/cwChatConnector' import { Connector as FeatureDevChatConnector } from './apps/featureDevChatConnector' import { Connector as AmazonQCommonsConnector } from './apps/amazonqCommonsConnector' @@ -50,7 +57,7 @@ export interface ConnectorProps { sendMessageToExtension: (message: ExtensionMessage) => void onMessageReceived?: (tabID: string, messageData: any, needToShowAPIDocsTab: boolean) => void onChatAnswerUpdated?: (tabID: string, message: ChatItem) => void - onChatAnswerReceived?: (tabID: string, message: ChatItem) => void + onChatAnswerReceived?: (tabID: string, message: ChatItem, messageData: any) => void onWelcomeFollowUpClicked: (tabID: string, welcomeFollowUpType: WelcomeFollowupType) => void onAsyncEventProgress: (tabID: string, inProgress: boolean, message: string | undefined) => void onQuickHandlerCommand: (tabID: string, command?: string, eventId?: string) => void @@ -187,6 +194,9 @@ export class Connector { } else if (messageData.sender === 'gumbyChat') { await this.gumbyChatConnector.handleMessageReceive(messageData) } + + // Reset lastCommand after message is rendered. + this.tabsStorage.updateTabLastCommand(messageData.tabID, '') } onTabAdd = (tabID: string): void => { @@ -254,6 +264,64 @@ export class Connector { } } + onAcceptDiff = ( + tabId: string, + messageId: string, + actionId: string, + data?: string, + code?: string, + type?: CodeSelectionType, + referenceTrackerInformation?: ReferenceTrackerInformation[], + eventId?: string, + codeBlockIndex?: number, + totalCodeBlocks?: number + ) => { + const tabType = this.tabsStorage.getTab(tabId)?.type + this.sendMessageToExtension({ + tabType, + tabID: tabId, + command: 'accept_diff', + messageId, + actionId, + data, + code, + type, + referenceTrackerInformation, + eventId, + codeBlockIndex, + totalCodeBlocks, + }) + } + + onViewDiff = ( + tabId: string, + messageId: string, + actionId: string, + data?: string, + code?: string, + type?: CodeSelectionType, + referenceTrackerInformation?: ReferenceTrackerInformation[], + eventId?: string, + codeBlockIndex?: number, + totalCodeBlocks?: number + ) => { + const tabType = this.tabsStorage.getTab(tabId)?.type + this.sendMessageToExtension({ + tabType, + tabID: tabId, + command: 'view_diff', + messageId, + actionId, + data, + code, + type, + referenceTrackerInformation, + eventId, + codeBlockIndex, + totalCodeBlocks, + }) + } + onCopyCodeToClipboard = ( tabID: string, messageId: string, diff --git a/packages/core/src/amazonq/webview/ui/main.ts b/packages/core/src/amazonq/webview/ui/main.ts index 09064d5faf8..aa5ad615d44 100644 --- a/packages/core/src/amazonq/webview/ui/main.ts +++ b/packages/core/src/amazonq/webview/ui/main.ts @@ -3,7 +3,16 @@ * SPDX-License-Identifier: Apache-2.0 */ import { Connector, CWCChatItem } from './connector' -import { ChatItem, ChatItemType, MynahIcons, MynahUI, MynahUIDataModel, NotificationType } from '@aws/mynah-ui' +import { + ChatItem, + ChatItemType, + CodeSelectionType, + MynahIcons, + MynahUI, + MynahUIDataModel, + NotificationType, + ReferenceTrackerInformation, +} from '@aws/mynah-ui' import { ChatPrompt } from '@aws/mynah-ui/dist/static' import { TabsStorage, TabType } from './storages/tabsStorage' import { WelcomeFollowupType } from './apps/amazonqCommonsConnector' @@ -16,8 +25,14 @@ import { TextMessageHandler } from './messages/handler' import { MessageController } from './messages/controller' import { getActions, getDetails } from './diffTree/actions' import { DiffTreeFileInfo } from './diffTree/types' +import { FeatureContext } from '../../../shared' +import { tryNewMap } from '../../util/functionUtils' -export const createMynahUI = (ideApi: any, amazonQEnabled: boolean) => { +export const createMynahUI = ( + ideApi: any, + amazonQEnabled: boolean, + featureConfigsSerialized: [string, FeatureContext][] +) => { // eslint-disable-next-line prefer-const let mynahUI: MynahUI // eslint-disable-next-line prefer-const @@ -73,6 +88,23 @@ export const createMynahUI = (ideApi: any, amazonQEnabled: boolean) => { // eslint-disable-next-line prefer-const let messageController: MessageController + let featureConfigs: Map = tryNewMap(featureConfigsSerialized) + + function shouldDisplayDiff(messageData: any) { + const isEnabled = featureConfigs.get('ViewDiffInChat')?.variation === 'TREATMENT' + const tab = tabsStorage.getTab(messageData?.tabID || '') + const allowedCommands = [ + 'aws.amazonq.refactorCode', + 'aws.amazonq.fixCode', + 'aws.amazonq.optimizeCode', + 'aws.amazonq.sendToPrompt', + ] + if (isEnabled && tab?.type === 'cwc' && allowedCommands.includes(tab.lastCommand || '')) { + return true + } + return false + } + // eslint-disable-next-line prefer-const connector = new Connector({ tabsStorage, @@ -92,6 +124,9 @@ export const createMynahUI = (ideApi: any, amazonQEnabled: boolean) => { isFeatureDevEnabled, isGumbyEnabled, }) + + featureConfigs = tryNewMap(featureConfigsSerialized) + // Set the new defaults for the quick action commands in all tabs now that isFeatureDevEnabled was enabled/disabled for (const tab of tabsStorage.getTabs()) { mynahUI.updateStore(tab.id, { @@ -117,6 +152,7 @@ export const createMynahUI = (ideApi: any, amazonQEnabled: boolean) => { }, onFileActionClick: (tabID: string, messageId: string, filePath: string, actionName: string): void => {}, onQuickHandlerCommand: (tabID: string, command?: string, eventId?: string) => { + tabsStorage.updateTabLastCommand(tabID, command) if (command === 'aws.awsq.transform') { quickActionHandler.handle({ command: '/transform' }, tabID, eventId) } else if (command === 'aws.awsq.clearchat') { @@ -124,10 +160,13 @@ export const createMynahUI = (ideApi: any, amazonQEnabled: boolean) => { } }, onCWCContextCommandMessage: (message: ChatItem, command?: string): string | undefined => { + const selectedTab = tabsStorage.getSelectedTab() + tabsStorage.updateTabLastCommand(selectedTab?.id || '', command || '') + if (command === 'aws.amazonq.sendToPrompt') { - return messageController.sendSelectedCodeToTab(message) + return messageController.sendSelectedCodeToTab(message, command) } else { - const tabID = messageController.sendMessageToTab(message, 'cwc') + const tabID = messageController.sendMessageToTab(message, 'cwc', command) if (tabID) { ideApi.postMessage({ command: 'start-chat-message-telemetry', @@ -201,7 +240,7 @@ export const createMynahUI = (ideApi: any, amazonQEnabled: boolean) => { } as ChatItem) } }, - onChatAnswerReceived: (tabID: string, item: CWCChatItem) => { + onChatAnswerReceived: (tabID: string, item: CWCChatItem, messageData: any) => { if (item.type === ChatItemType.ANSWER_PART || item.type === ChatItemType.CODE_RESULT) { mynahUI.updateLastChatAnswer(tabID, { ...(item.messageId !== undefined ? { messageId: item.messageId } : {}), @@ -232,7 +271,29 @@ export const createMynahUI = (ideApi: any, amazonQEnabled: boolean) => { item.formItems !== undefined || item.buttons !== undefined ) { - mynahUI.addChatItem(tabID, item) + mynahUI.addChatItem(tabID, { + ...item, + messageId: item.messageId, + codeBlockActions: { + ...(shouldDisplayDiff(messageData) + ? { + 'insert-to-cursor': undefined, + accept_diff: { + id: 'accept_diff', + label: 'Apply Diff', + icon: MynahIcons.OK_CIRCLED, + data: messageData, + }, + view_diff: { + id: 'view_diff', + label: 'View Diff', + icon: MynahIcons.EYE, + data: messageData, + }, + } + : {}), + }, + }) } if ( @@ -359,6 +420,7 @@ export const createMynahUI = (ideApi: any, amazonQEnabled: boolean) => { connector.onUpdateTabType(newTabID) mynahUI.updateStore(newTabID, tabDataGenerator.getTabData(tabType, true)) + featureConfigs = tryNewMap(featureConfigsSerialized) }, onOpenSettingsMessage(tabId: string) { mynahUI.addChatItem(tabId, { @@ -456,6 +518,51 @@ export const createMynahUI = (ideApi: any, amazonQEnabled: boolean) => { messageUserIntentMap.get(messageId) ?? undefined ) }, + onCodeBlockActionClicked: ( + tabId: string, + messageId: string, + actionId: string, + data?: string, + code?: string, + type?: CodeSelectionType, + referenceTrackerInformation?: ReferenceTrackerInformation[], + eventId?: string, + codeBlockIndex?: number, + totalCodeBlocks?: number + ) => { + switch (actionId) { + case 'accept_diff': + connector.onAcceptDiff( + tabId, + messageId, + actionId, + data, + code, + type, + referenceTrackerInformation, + eventId, + codeBlockIndex, + totalCodeBlocks + ) + break + case 'view_diff': + connector.onViewDiff( + tabId, + messageId, + actionId, + data, + code, + type, + referenceTrackerInformation, + eventId, + codeBlockIndex, + totalCodeBlocks + ) + break + default: + break + } + }, onCopyCodeToClipboard: ( tabId, messageId, diff --git a/packages/core/src/amazonq/webview/ui/messages/controller.ts b/packages/core/src/amazonq/webview/ui/messages/controller.ts index a63034c637a..26665a4f7d1 100644 --- a/packages/core/src/amazonq/webview/ui/messages/controller.ts +++ b/packages/core/src/amazonq/webview/ui/messages/controller.ts @@ -33,7 +33,7 @@ export class MessageController { }) } - public sendSelectedCodeToTab(message: ChatItem): string | undefined { + public sendSelectedCodeToTab(message: ChatItem, command: string = ''): string | undefined { const selectedTab = { ...this.tabsStorage.getSelectedTab() } if (selectedTab?.id === undefined || selectedTab?.type === 'featuredev') { // Create a new tab if there's none @@ -53,6 +53,7 @@ export class MessageController { type: 'cwc', status: 'free', isSelected: true, + lastCommand: command, }) selectedTab.id = newTabID } @@ -61,7 +62,7 @@ export class MessageController { return selectedTab.id } - public sendMessageToTab(message: ChatItem, tabType: TabType): string | undefined { + public sendMessageToTab(message: ChatItem, tabType: TabType, command: string = ''): string | undefined { const selectedTab = this.tabsStorage.getSelectedTab() if ( @@ -71,6 +72,7 @@ export class MessageController { ) { this.tabsStorage.updateTabStatus(selectedTab.id, 'busy') this.tabsStorage.updateTabTypeFromUnknown(selectedTab.id, tabType) + this.tabsStorage.updateTabLastCommand(selectedTab.id, command) this.mynahUI.updateStore(selectedTab.id, { loadingChat: true, @@ -96,6 +98,7 @@ export class MessageController { }) return undefined } else { + this.tabsStorage.updateTabLastCommand(newTabID, command) this.mynahUI.addChatItem(newTabID, message) this.mynahUI.addChatItem(newTabID, { type: ChatItemType.ANSWER_STREAM, @@ -114,6 +117,7 @@ export class MessageController { status: 'busy', isSelected: true, openInteractionType: 'contextMenu', + lastCommand: command, }) this.tabsStorage.updateTabTypeFromUnknown(newTabID, 'cwc') diff --git a/packages/core/src/amazonq/webview/ui/messages/handler.ts b/packages/core/src/amazonq/webview/ui/messages/handler.ts index eeec2e65745..e77006cea9e 100644 --- a/packages/core/src/amazonq/webview/ui/messages/handler.ts +++ b/packages/core/src/amazonq/webview/ui/messages/handler.ts @@ -25,6 +25,7 @@ export class TextMessageHandler { } public handle(chatPrompt: ChatPrompt, tabID: string, eventID: string) { + this.tabsStorage.updateTabLastCommand(tabID, chatPrompt.command) this.tabsStorage.updateTabTypeFromUnknown(tabID, 'cwc') this.tabsStorage.resetTabTimer(tabID) this.connector.onUpdateTabType(tabID) diff --git a/packages/core/src/amazonq/webview/ui/storages/tabsStorage.ts b/packages/core/src/amazonq/webview/ui/storages/tabsStorage.ts index c858169315b..564dd8a7a69 100644 --- a/packages/core/src/amazonq/webview/ui/storages/tabsStorage.ts +++ b/packages/core/src/amazonq/webview/ui/storages/tabsStorage.ts @@ -14,6 +14,7 @@ export interface Tab { type: TabType isSelected: boolean openInteractionType?: TabOpenType + lastCommand?: string } export class TabsStorage { @@ -62,6 +63,18 @@ export class TabsStorage { return this.tabs.get(tabID)?.status === 'dead' } + public updateTabLastCommand(tabID: string, command?: string) { + if (command === undefined) { + return + } + const currentTabValue = this.tabs.get(tabID) + if (currentTabValue === undefined || currentTabValue.status === 'dead') { + return + } + currentTabValue.lastCommand = command + this.tabs.set(tabID, currentTabValue) + } + public updateTabStatus(tabID: string, tabStatus: TabStatus) { const currentTabValue = this.tabs.get(tabID) if (currentTabValue === undefined || currentTabValue.status === 'dead') { diff --git a/packages/core/src/codewhispererChat/app.ts b/packages/core/src/codewhispererChat/app.ts index f65352ecc10..6781cde30e5 100644 --- a/packages/core/src/codewhispererChat/app.ts +++ b/packages/core/src/codewhispererChat/app.ts @@ -10,6 +10,7 @@ import { AmazonQAppInitContext } from '../amazonq/apps/initContext' import { MessageListener } from '../amazonq/messages/messageListener' import { MessagePublisher } from '../amazonq/messages/messagePublisher' import { + ViewDiff, ChatItemFeedbackMessage, ChatItemVotedMessage, CopyCodeToClipboard, @@ -24,6 +25,7 @@ import { TabCreatedMessage, TriggerTabIDReceived, UIFocusMessage, + AcceptDiff, } from './controllers/chat/model' import { EditorContextCommand, registerCommands } from './commands/registerCommands' @@ -34,6 +36,8 @@ export function init(appContext: AmazonQAppInitContext) { processTabClosedMessage: new EventEmitter(), processTabChangedMessage: new EventEmitter(), processInsertCodeAtCursorPosition: new EventEmitter(), + processAcceptDiff: new EventEmitter(), + processViewDiff: new EventEmitter(), processCopyCodeToClipboard: new EventEmitter(), processContextMenuCommand: new EventEmitter(), processTriggerTabIDReceived: new EventEmitter(), @@ -62,6 +66,8 @@ export function init(appContext: AmazonQAppInitContext) { processInsertCodeAtCursorPosition: new MessageListener( cwChatControllerEventEmitters.processInsertCodeAtCursorPosition ), + processAcceptDiff: new MessageListener(cwChatControllerEventEmitters.processAcceptDiff), + processViewDiff: new MessageListener(cwChatControllerEventEmitters.processViewDiff), processCopyCodeToClipboard: new MessageListener( cwChatControllerEventEmitters.processCopyCodeToClipboard ), @@ -108,6 +114,8 @@ export function init(appContext: AmazonQAppInitContext) { processInsertCodeAtCursorPosition: new MessagePublisher( cwChatControllerEventEmitters.processInsertCodeAtCursorPosition ), + processAcceptDiff: new MessagePublisher(cwChatControllerEventEmitters.processAcceptDiff), + processViewDiff: new MessagePublisher(cwChatControllerEventEmitters.processViewDiff), processCopyCodeToClipboard: new MessagePublisher( cwChatControllerEventEmitters.processCopyCodeToClipboard ), diff --git a/packages/core/src/codewhispererChat/controllers/chat/controller.ts b/packages/core/src/codewhispererChat/controllers/chat/controller.ts index e4680c02571..39eadd7e689 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/controller.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/controller.ts @@ -24,6 +24,8 @@ import { ResponseBodyLinkClickMessage, ChatPromptCommandType, FooterInfoLinkClick, + ViewDiff, + AcceptDiff, } from './model' import { AppToWebViewMessageDispatcher } from '../../view/connector/connector' import { MessagePublisher } from '../../../amazonq/messages/messagePublisher' @@ -59,6 +61,8 @@ export interface ChatControllerMessagePublishers { readonly processTabClosedMessage: MessagePublisher readonly processTabChangedMessage: MessagePublisher readonly processInsertCodeAtCursorPosition: MessagePublisher + readonly processAcceptDiff: MessagePublisher + readonly processViewDiff: MessagePublisher readonly processCopyCodeToClipboard: MessagePublisher readonly processContextMenuCommand: MessagePublisher readonly processTriggerTabIDReceived: MessagePublisher @@ -77,6 +81,8 @@ export interface ChatControllerMessageListeners { readonly processTabClosedMessage: MessageListener readonly processTabChangedMessage: MessageListener readonly processInsertCodeAtCursorPosition: MessageListener + readonly processAcceptDiff: MessageListener + readonly processViewDiff: MessageListener readonly processCopyCodeToClipboard: MessageListener readonly processContextMenuCommand: MessageListener readonly processTriggerTabIDReceived: MessageListener @@ -159,6 +165,14 @@ export class ChatController { return this.processInsertCodeAtCursorPosition(data) }) + this.chatControllerMessageListeners.processAcceptDiff.onMessage((data) => { + return this.processAcceptDiff(data) + }) + + this.chatControllerMessageListeners.processViewDiff.onMessage((data) => { + return this.processViewDiff(data) + }) + this.chatControllerMessageListeners.processCopyCodeToClipboard.onMessage((data) => { return this.processCopyCodeToClipboard(data) }) @@ -278,6 +292,30 @@ export class ChatController { this.telemetryHelper.recordInteractWithMessage(message) } + private async processAcceptDiff(message: AcceptDiff) { + const context = this.triggerEventsStorage.getTriggerEvent((message.data as any)?.triggerID) || '' + this.editorContentController + .acceptDiff({ ...message, ...context }) + .then(() => { + this.telemetryHelper.recordInteractWithMessage(message) + }) + .catch((error) => { + this.telemetryHelper.recordInteractWithMessage(message, { result: 'Failed' }) + }) + } + + private async processViewDiff(message: ViewDiff) { + const context = this.triggerEventsStorage.getTriggerEvent((message.data as any)?.triggerID) || '' + this.editorContentController + .viewDiff({ ...message, ...context }) + .then(() => { + this.telemetryHelper.recordInteractWithMessage(message) + }) + .catch((error) => { + this.telemetryHelper.recordInteractWithMessage(message, { result: 'Failed' }) + }) + } + private async processCopyCodeToClipboard(message: CopyCodeToClipboard) { this.telemetryHelper.recordInteractWithMessage(message) } diff --git a/packages/core/src/codewhispererChat/controllers/chat/model.ts b/packages/core/src/codewhispererChat/controllers/chat/model.ts index 94e7d0d10cc..d2f7e4ca627 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/model.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/model.ts @@ -61,6 +61,31 @@ export interface CopyCodeToClipboard { totalCodeBlocks: number } +export interface AcceptDiff { + command: string | undefined + tabID: string // rename tabId + messageId: string + actionId: string + data: string + code: string + referenceTrackerInformation?: CodeReference[] + eventId: string + codeBlockIndex?: number + totalCodeBlocks?: number +} +export interface ViewDiff { + command: string | undefined + tabID: string // rename tabId + messageId: string + actionId: string + data: string + code: string + referenceTrackerInformation?: CodeReference[] + eventId: string + codeBlockIndex?: number + totalCodeBlocks?: number +} + export type ChatPromptCommandType = | 'help' | 'clear' diff --git a/packages/core/src/codewhispererChat/controllers/chat/telemetryHelper.ts b/packages/core/src/codewhispererChat/controllers/chat/telemetryHelper.ts index a86d8c0ff68..e5b49a6d59c 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/telemetryHelper.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/telemetryHelper.ts @@ -15,6 +15,8 @@ import { } from '../../../shared/telemetry/telemetry' import { ChatSessionStorage } from '../../storages/chatSession' import { + AcceptDiff, + ViewDiff, ChatItemFeedbackMessage, ChatItemVotedMessage, CopyCodeToClipboard, @@ -171,6 +173,7 @@ export class CWCTelemetryHelper { public recordInteractWithMessage( message: + | AcceptDiff | InsertCodeAtCursorPosition | CopyCodeToClipboard | PromptMessage @@ -178,6 +181,8 @@ export class CWCTelemetryHelper { | SourceLinkClickMessage | ResponseBodyLinkClickMessage | FooterInfoLinkClick + | ViewDiff, + { result }: { result: Result } = { result: 'Succeeded' } ) { const conversationId = this.getConversationId(message.tabID) let event: AmazonqInteractWithMessage | undefined @@ -185,7 +190,7 @@ export class CWCTelemetryHelper { case 'insert_code_at_cursor_position': message = message as InsertCodeAtCursorPosition event = { - result: 'Succeeded', + result, cwsprChatConversationId: conversationId ?? '', credentialStartUrl: AuthUtil.instance.startUrl, cwsprChatMessageId: message.messageId, @@ -203,7 +208,7 @@ export class CWCTelemetryHelper { case 'code_was_copied_to_clipboard': message = message as CopyCodeToClipboard event = { - result: 'Succeeded', + result, cwsprChatConversationId: conversationId ?? '', credentialStartUrl: AuthUtil.instance.startUrl, cwsprChatMessageId: message.messageId, @@ -217,10 +222,42 @@ export class CWCTelemetryHelper { cwsprChatHasProjectContext: this.responseWithProjectContext.get(message.messageId), } break + case 'accept_diff': + message = message as AcceptDiff + event = { + result, + cwsprChatConversationId: conversationId ?? '', + cwsprChatMessageId: message.messageId, + cwsprChatInteractionType: 'acceptDiff', + credentialStartUrl: AuthUtil.instance.startUrl, + cwsprChatAcceptedCharactersLength: message.code.length, + cwsprChatHasReference: + message.referenceTrackerInformation && message.referenceTrackerInformation.length > 0, + cwsprChatCodeBlockIndex: message.codeBlockIndex, + cwsprChatTotalCodeBlocks: message.totalCodeBlocks, + cwsprChatHasProjectContext: this.responseWithProjectContext.get(message.messageId), + } + break + case 'view_diff': + message = message as ViewDiff + event = { + result, + cwsprChatConversationId: conversationId ?? '', + cwsprChatMessageId: message.messageId, + cwsprChatInteractionType: 'viewDiff', + credentialStartUrl: AuthUtil.instance.startUrl, + cwsprChatAcceptedCharactersLength: message.code.length, + cwsprChatHasReference: + message.referenceTrackerInformation && message.referenceTrackerInformation.length > 0, + cwsprChatCodeBlockIndex: message.codeBlockIndex, + cwsprChatTotalCodeBlocks: message.totalCodeBlocks, + cwsprChatHasProjectContext: this.responseWithProjectContext.get(message.messageId), + } + break case 'follow-up-was-clicked': message = message as PromptMessage event = { - result: 'Succeeded', + result, cwsprChatConversationId: conversationId ?? '', credentialStartUrl: AuthUtil.instance.startUrl, cwsprChatMessageId: message.messageId, @@ -231,7 +268,7 @@ export class CWCTelemetryHelper { case 'chat-item-voted': message = message as ChatItemVotedMessage event = { - result: 'Succeeded', + result, cwsprChatMessageId: message.messageId, cwsprChatConversationId: conversationId ?? '', credentialStartUrl: AuthUtil.instance.startUrl, @@ -242,7 +279,7 @@ export class CWCTelemetryHelper { case 'source-link-click': message = message as SourceLinkClickMessage event = { - result: 'Succeeded', + result, cwsprChatMessageId: message.messageId, cwsprChatConversationId: conversationId ?? '', credentialStartUrl: AuthUtil.instance.startUrl, @@ -254,7 +291,7 @@ export class CWCTelemetryHelper { case 'response-body-link-click': message = message as ResponseBodyLinkClickMessage event = { - result: 'Succeeded', + result, cwsprChatMessageId: message.messageId, cwsprChatConversationId: conversationId ?? '', credentialStartUrl: AuthUtil.instance.startUrl, @@ -266,7 +303,7 @@ export class CWCTelemetryHelper { case 'footer-info-link-click': message = message as FooterInfoLinkClick event = { - result: 'Succeeded', + result, cwsprChatMessageId: 'footer', cwsprChatConversationId: conversationId ?? '', credentialStartUrl: AuthUtil.instance.startUrl, diff --git a/packages/core/src/codewhispererChat/view/messages/messageListener.ts b/packages/core/src/codewhispererChat/view/messages/messageListener.ts index d9214f43365..abe37c019fd 100644 --- a/packages/core/src/codewhispererChat/view/messages/messageListener.ts +++ b/packages/core/src/codewhispererChat/view/messages/messageListener.ts @@ -63,6 +63,12 @@ export class UIMessageListener { }) } break + case 'accept_diff': + this.processAcceptDiff(msg) + break + case 'view_diff': + this.processViewDiff(msg) + break case 'code_was_copied_to_clipboard': this.processCodeWasCopiedToClipboard(msg) break @@ -162,6 +168,22 @@ export class UIMessageListener { }) } + private processAcceptDiff(msg: any) { + this.chatControllerMessagePublishers.processAcceptDiff.publish({ + command: msg.command, + tabID: msg.tabID || msg.tabId, + ...msg, + }) + } + + private processViewDiff(msg: any) { + this.chatControllerMessagePublishers.processViewDiff.publish({ + command: msg.command, + tabID: msg.tabID || msg.tabId, + ...msg, + }) + } + private processCodeWasCopiedToClipboard(msg: any) { this.chatControllerMessagePublishers.processCopyCodeToClipboard.publish({ command: msg.command, diff --git a/packages/core/src/shared/constants.ts b/packages/core/src/shared/constants.ts index 6d6cf842552..98e61d220f0 100644 --- a/packages/core/src/shared/constants.ts +++ b/packages/core/src/shared/constants.ts @@ -129,6 +129,7 @@ export const ecsIamPermissionsUrl = vscode.Uri.parse( */ export const CLOUDWATCH_LOGS_SCHEME = 'aws-cwl' // eslint-disable-line @typescript-eslint/naming-convention export const AWS_SCHEME = 'aws' // eslint-disable-line @typescript-eslint/naming-convention +export const amazonQDiffScheme = 'amazon-q-diff' export const lambdaPackageTypeImage = 'Image' @@ -168,3 +169,5 @@ export const crashMonitoringDirNames = { running: 'running', shutdown: 'shutdown', } as const + +export const amazonQTabSuffix = '(Generated by Amazon Q)' diff --git a/packages/core/src/shared/errors.ts b/packages/core/src/shared/errors.ts index 30b07a55314..6e4845dabee 100644 --- a/packages/core/src/shared/errors.ts +++ b/packages/core/src/shared/errors.ts @@ -218,7 +218,24 @@ export class ToolkitError extends Error implements ErrorInformation { } /** - * Creates a new {@link ToolkitError} instance that was directly caused by another {@link error}. + * Creates a new {@link ToolkitError} instance that was directly caused by another error. + * + * @param error - The original error that caused this error. + * @param message - A descriptive message for the new error. + * @param info - Additional information about the error. + * @returns {ToolkitError} The new ToolkitError instance. + * + * @recommendation It is recommended to throw the returned ToolkitError instance instead of just returning it. + * This way, the error can be properly propagated and handled in the calling code. + * + * Example: + * ```typescript + * try { + * // Some code that might throw an error + * } catch (error) { + * throw ToolkitError.chain(error, 'An error occurred during operation', { operation: 'someOperation' }); + * } + * ``` */ public static chain(error: unknown, message: string, info?: Omit): ToolkitError { return new this(message, { diff --git a/packages/core/src/shared/index.ts b/packages/core/src/shared/index.ts index e60d18f84ff..3ae076bf53e 100644 --- a/packages/core/src/shared/index.ts +++ b/packages/core/src/shared/index.ts @@ -53,3 +53,5 @@ export * as funcUtil from './utilities/functionUtils' export { fs } from './fs/fs' export * from './handleUninstall' export { CrashMonitoring } from './crashMonitoring' +export { amazonQDiffScheme } from './constants' +export * from './featureConfig' diff --git a/packages/core/src/shared/telemetry/vscodeTelemetry.json b/packages/core/src/shared/telemetry/vscodeTelemetry.json index a1dea2d6b03..1a6f82b977a 100644 --- a/packages/core/src/shared/telemetry/vscodeTelemetry.json +++ b/packages/core/src/shared/telemetry/vscodeTelemetry.json @@ -202,6 +202,7 @@ { "name": "cwsprChatInteractionType", "allowedValues": [ + "acceptDiff", "insertAtCursor", "copySnippet", "copy", @@ -210,7 +211,8 @@ "hoverReference", "upvote", "downvote", - "clickBodyLink" + "clickBodyLink", + "viewDiff" ], "type": "string", "description": "Indicates the specific interaction type with a message in a conversation" diff --git a/packages/core/src/shared/utilities/editorUtilities.ts b/packages/core/src/shared/utilities/editorUtilities.ts index 4fedcd1164d..2528dda1a76 100644 --- a/packages/core/src/shared/utilities/editorUtilities.ts +++ b/packages/core/src/shared/utilities/editorUtilities.ts @@ -60,3 +60,21 @@ export async function getOpenFilesInWindow( return filesOpenedInEditor } } + +/** + * Disposes of resources (content provider) when the temporary diff editor is closed. + * + * @param {vscode.Uri} tempFileUri - The URI of the temporary file used for diff comparison. + * @param {vscode.Disposable} disposable - The disposable resource to be cleaned up (e.g., content provider). + */ +export function disposeOnEditorClose(tempFileUri: vscode.Uri, disposable: vscode.Disposable) { + vscode.window.onDidChangeVisibleTextEditors(() => { + if ( + !vscode.window.visibleTextEditors.some( + (editor) => editor.document.uri.toString() === tempFileUri.toString() + ) + ) { + disposable.dispose() + } + }) +} diff --git a/packages/core/src/shared/utilities/textDocumentUtilities.ts b/packages/core/src/shared/utilities/textDocumentUtilities.ts index e34eed33e0e..4d5805ef639 100644 --- a/packages/core/src/shared/utilities/textDocumentUtilities.ts +++ b/packages/core/src/shared/utilities/textDocumentUtilities.ts @@ -6,6 +6,9 @@ import * as _path from 'path' import * as vscode from 'vscode' import { getTabSizeSetting } from './editorUtilities' +import { tempDirPath } from '../filesystemUtilities' +import { fs, getIndentedCode, ToolkitError } from '../index' +import { getLogger } from '../logger' /** * Finds occurences of text in a document. Currently only used for highlighting cloudwatchlogs data. @@ -76,3 +79,89 @@ export function getTabSize(editor?: vscode.TextEditor): number { return getTabSizeSetting() } } + +/** + * Creates a selection range from the given document and selection. + * If a user selects a partial code, this function generates the range from start line to end line. + * + * @param {vscode.TextDocument} doc - The VSCode document where the selection is applied. + * @param {vscode.Selection} selection - The selection range in the document. + * @returns {vscode.Range} - The VSCode range object representing the start and end of the selection. + */ +export function getSelectionFromRange(doc: vscode.TextDocument, selection: vscode.Selection) { + return new vscode.Range( + new vscode.Position(selection.start.line, 0), + new vscode.Position(selection.end.line, doc.lineAt(selection.end.line).range.end.character) + ) +} + +/** + * Applies the given code to the specified range in the document. + * Saves the document after the edit is successfully applied. + * + * @param {vscode.TextDocument} doc - The VSCode document to which the changes are applied. + * @param {vscode.Range} range - The range in the document where the code is replaced. + * @param {string} code - The code to be applied to the document. + * @returns {Promise} - Resolves when the changes are successfully applied and the document is saved. + */ +export async function applyChanges(doc: vscode.TextDocument, range: vscode.Range, code: string) { + const edit = new vscode.WorkspaceEdit() + edit.replace(doc.uri, range, code) + const successfulEdit = await vscode.workspace.applyEdit(edit) + if (successfulEdit) { + getLogger().debug('Diff: Edits successfully applied to: %s', doc.uri.fsPath) + await doc.save() + } else { + getLogger().error('Diff: Unable to apply changes to: %s', doc.uri.fsPath) + } +} + +/** + * Creates a temporary file for diff comparison by cloning the original file + * and applying the proposed changes within the selected range. + * + * @param {vscode.Uri} originalFileUri - The URI of the original file. + * @param {any} message - The message object containing the proposed code changes. + * @param {vscode.Selection} selection - The selection range in the document where the changes are applied. + * @returns {Promise} - A promise that resolves to the URI of the temporary file. + */ +export async function createTempFileForDiff( + originalFileUri: vscode.Uri, + message: any, + selection: vscode.Selection, + scheme: string +): Promise { + const errorCode = 'createTempFile' + const id = Date.now() + const languageId = (await vscode.workspace.openTextDocument(originalFileUri)).languageId + const tempFile = _path.parse(originalFileUri.path) + const tempFilePath = _path.join(tempDirPath, `${tempFile.name}_proposed-${id}${tempFile.ext}`) + await fs.mkdir(tempDirPath) + const tempFileUri = vscode.Uri.parse(`${scheme}:${tempFilePath}`) + getLogger().debug('Diff: Creating temp file: %s', tempFileUri.fsPath) + + try { + // Write original content to temp file + await fs.writeFile(tempFilePath, await fs.readFileText(originalFileUri.fsPath)) + } catch (error) { + if (!(error instanceof Error)) { + throw error + } + throw ToolkitError.chain(error, 'Failed to write to temp file', { code: errorCode }) + } + + // Apply the proposed changes to the temp file + const doc = await vscode.workspace.openTextDocument(tempFileUri.path) + const languageIdStatus = await vscode.languages.setTextDocumentLanguage(doc, languageId) + if (languageIdStatus) { + getLogger().debug('Diff: languageId for %s is set to: %s', tempFileUri.fsPath, languageId) + } else { + getLogger().error('Diff: Unable to set languageId for %s to: %s', tempFileUri.fsPath, languageId) + } + + const code = getIndentedCode(message, doc, selection) + const range = getSelectionFromRange(doc, selection) + + await applyChanges(doc, range, code) + return tempFileUri +} diff --git a/packages/core/src/shared/utilities/textUtilities.ts b/packages/core/src/shared/utilities/textUtilities.ts index 92e2342d97c..d0af2e6de52 100644 --- a/packages/core/src/shared/utilities/textUtilities.ts +++ b/packages/core/src/shared/utilities/textUtilities.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as vscode from 'vscode' import * as crypto from 'crypto' import * as fs from 'fs' // eslint-disable-line no-restricted-imports import { default as stripAnsi } from 'strip-ansi' @@ -30,11 +31,20 @@ export function truncate(s: string, n: number, suffix?: string): string { } /** - * Indents a string. + * Indents a given string with spaces. * - * @param size Indent width (number of space chars). - * @param clear Clear existing whitespace, if any. - * @param s Text to indent. + * @param {string} s - The input string to be indented. + * @param {number} [size=4] - The number of spaces to use for indentation. Defaults to 4. + * @param {boolean} [clear=false] - If true, the function will clear any existing indentation and apply the new indentation. + * @returns {string} The indented string. + * + * @example + * const indentedString = indent('Hello\nWorld', 2); + * console.log(indentedString); // Output: " Hello\n World" + * + * @example + * const indentedString = indent(' Hello\n World', 4, true); + * console.log(indentedString); // Output: " Hello\n World" */ export function indent(s: string, size: number = 4, clear: boolean = false): string { const n = Math.abs(size) @@ -395,3 +405,34 @@ export function undefinedIfEmpty(str: string | undefined): string | undefined { return undefined } + +/** + * Extracts the file path and selection context from the message. + * + * @param {any} message - The message object containing the file and selection context. + * @returns {Object} - An object with `filePath` and `selection` properties. + */ +export function extractFileAndCodeSelectionFromMessage(message: any) { + const filePath = message?.context?.activeFileContext?.filePath + const selection = message?.context?.focusAreaContext?.selectionInsideExtendedCodeBlock as vscode.Selection + return { filePath, selection } +} + +/** + * Indents the given code based on the current document's indentation at the selection start. + * + * @param {any} message - The message object containing the code. + * @param {vscode.TextDocument} doc - The VSCode document where the code is applied. + * @param {vscode.Selection} selection - The selection range in the document. + * @returns {string} - The processed code to be applied to the document. + */ +export function getIndentedCode(message: any, doc: vscode.TextDocument, selection: vscode.Selection) { + const indentRange = new vscode.Range(new vscode.Position(selection.start.line, 0), selection.active) + let indentation = doc.getText(indentRange) + + if (indentation.trim().length !== 0) { + indentation = ' '.repeat(indentation.length - indentation.trimStart().length) + } + + return indent(message.code, indentation.length) +} From d33c256b3233342c445a145b33bd72b321730797 Mon Sep 17 00:00:00 2001 From: Vikash Agrawal Date: Thu, 10 Oct 2024 11:47:46 -0700 Subject: [PATCH 3/3] refactor(amazonq): move getIndentedCode, cleanup telemetry #5765 - move getIndentedCode to textDocumentUtilities - use cwsprChatInteractionType from aws-toolkit-common --- .../commons/controllers/contentController.ts | 3 ++- .../core/src/amazonq/util/functionUtils.ts | 2 +- .../src/shared/telemetry/vscodeTelemetry.json | 18 ---------------- .../shared/utilities/textDocumentUtilities.ts | 21 ++++++++++++++++++- .../src/shared/utilities/textUtilities.ts | 19 ----------------- 5 files changed, 23 insertions(+), 40 deletions(-) diff --git a/packages/core/src/amazonq/commons/controllers/contentController.ts b/packages/core/src/amazonq/commons/controllers/contentController.ts index edcb84f9fb0..821e2988f96 100644 --- a/packages/core/src/amazonq/commons/controllers/contentController.ts +++ b/packages/core/src/amazonq/commons/controllers/contentController.ts @@ -12,9 +12,10 @@ import { disposeOnEditorClose } from '../../../shared/utilities/editorUtilities' import { applyChanges, createTempFileForDiff, + getIndentedCode, getSelectionFromRange, } from '../../../shared/utilities/textDocumentUtilities' -import { extractFileAndCodeSelectionFromMessage, fs, getErrorMsg, getIndentedCode, ToolkitError } from '../../../shared' +import { extractFileAndCodeSelectionFromMessage, fs, getErrorMsg, ToolkitError } from '../../../shared' class ContentProvider implements vscode.TextDocumentContentProvider { constructor(private uri: vscode.Uri) {} diff --git a/packages/core/src/amazonq/util/functionUtils.ts b/packages/core/src/amazonq/util/functionUtils.ts index c658b59aeef..b5d6a9bb9dc 100644 --- a/packages/core/src/amazonq/util/functionUtils.ts +++ b/packages/core/src/amazonq/util/functionUtils.ts @@ -4,7 +4,7 @@ */ /** - * Converts an array of key-value pairs into a Map object. + * Tries to create map and returns empty map if failed. * * @param {[unknown, unknown][]} arr - An array of tuples, where each tuple represents a key-value pair. * @returns {Map} A new Map object created from the input array. diff --git a/packages/core/src/shared/telemetry/vscodeTelemetry.json b/packages/core/src/shared/telemetry/vscodeTelemetry.json index 1a6f82b977a..005fc6c53ba 100644 --- a/packages/core/src/shared/telemetry/vscodeTelemetry.json +++ b/packages/core/src/shared/telemetry/vscodeTelemetry.json @@ -199,24 +199,6 @@ "type": "int", "description": "Number of characters in request" }, - { - "name": "cwsprChatInteractionType", - "allowedValues": [ - "acceptDiff", - "insertAtCursor", - "copySnippet", - "copy", - "clickLink", - "clickFollowUp", - "hoverReference", - "upvote", - "downvote", - "clickBodyLink", - "viewDiff" - ], - "type": "string", - "description": "Indicates the specific interaction type with a message in a conversation" - }, { "name": "cwsprChatInteractionTarget", "type": "string", diff --git a/packages/core/src/shared/utilities/textDocumentUtilities.ts b/packages/core/src/shared/utilities/textDocumentUtilities.ts index 4d5805ef639..71114bd2389 100644 --- a/packages/core/src/shared/utilities/textDocumentUtilities.ts +++ b/packages/core/src/shared/utilities/textDocumentUtilities.ts @@ -7,7 +7,7 @@ import * as _path from 'path' import * as vscode from 'vscode' import { getTabSizeSetting } from './editorUtilities' import { tempDirPath } from '../filesystemUtilities' -import { fs, getIndentedCode, ToolkitError } from '../index' +import { fs, indent, ToolkitError } from '../index' import { getLogger } from '../logger' /** @@ -165,3 +165,22 @@ export async function createTempFileForDiff( await applyChanges(doc, range, code) return tempFileUri } + +/** + * Indents the given code based on the current document's indentation at the selection start. + * + * @param message The message object containing the code. + * @param doc The VSCode document where the code is applied. + * @param selection The selection range in the document. + * @returns The processed code to be applied to the document. + */ +export function getIndentedCode(message: any, doc: vscode.TextDocument, selection: vscode.Selection) { + const indentRange = new vscode.Range(new vscode.Position(selection.start.line, 0), selection.active) + let indentation = doc.getText(indentRange) + + if (indentation.trim().length !== 0) { + indentation = ' '.repeat(indentation.length - indentation.trimStart().length) + } + + return indent(message.code, indentation.length) +} diff --git a/packages/core/src/shared/utilities/textUtilities.ts b/packages/core/src/shared/utilities/textUtilities.ts index d0af2e6de52..f337bb31276 100644 --- a/packages/core/src/shared/utilities/textUtilities.ts +++ b/packages/core/src/shared/utilities/textUtilities.ts @@ -417,22 +417,3 @@ export function extractFileAndCodeSelectionFromMessage(message: any) { const selection = message?.context?.focusAreaContext?.selectionInsideExtendedCodeBlock as vscode.Selection return { filePath, selection } } - -/** - * Indents the given code based on the current document's indentation at the selection start. - * - * @param {any} message - The message object containing the code. - * @param {vscode.TextDocument} doc - The VSCode document where the code is applied. - * @param {vscode.Selection} selection - The selection range in the document. - * @returns {string} - The processed code to be applied to the document. - */ -export function getIndentedCode(message: any, doc: vscode.TextDocument, selection: vscode.Selection) { - const indentRange = new vscode.Range(new vscode.Position(selection.start.line, 0), selection.active) - let indentation = doc.getText(indentRange) - - if (indentation.trim().length !== 0) { - indentation = ' '.repeat(indentation.length - indentation.trimStart().length) - } - - return indent(message.code, indentation.length) -}