From c09b09ad7f9e1d65acba4c1e1ad86a47ab28feca Mon Sep 17 00:00:00 2001 From: Kelvin Chu <131044785+kelvin-klchu@users.noreply.github.com> Date: Tue, 12 Nov 2024 16:27:58 -0500 Subject: [PATCH] feat(dev): File-level accepts of generated code #5928 ## Problem For the current /dev experience for code review, user needs to review the entire file list of generated code, reject the changes they do not want, and then accept the remaining changes in bulk at the end. This PR introduces improvements in user experience around the code review experience in which user can now review the file changes and accept them one at a time. User can start editing the file right away after accepting the changes without having to go through the entire generated file list. ## Solution - introduces a new action button for accepting file change for each change suggested on the file list - accepted changes are now highlighted in green - when accepting changes in bulk, the file list is updated to reflect the status if a change is rejected or accepted - minor text changes / improvements --- package-lock.json | 12 +- ...-d18cffcd-fd30-4936-9586-587b0ba88ec9.json | 4 + .../test/e2e/amazonq/featureDev.test.ts | 127 +++++++++- .../test/e2e/amazonq/framework/messenger.ts | 58 +++++ .../amazonqFeatureDev/session/session.test.ts | 2 + packages/core/package.json | 2 +- packages/core/package.nls.json | 3 + packages/core/src/amazonq/commons/diff.ts | 13 +- packages/core/src/amazonq/index.ts | 9 +- .../ui/apps/featureDevChatConnector.ts | 74 +++--- .../core/src/amazonq/webview/ui/commands.ts | 1 + .../core/src/amazonq/webview/ui/connector.ts | 3 +- .../amazonq/webview/ui/diffTree/actions.ts | 24 +- .../src/amazonq/webview/ui/diffTree/types.ts | 1 + packages/core/src/amazonq/webview/ui/main.ts | 11 +- .../src/amazonq/webview/ui/texts/constants.ts | 5 + packages/core/src/amazonqFeatureDev/app.ts | 1 + .../controllers/chat/controller.ts | 221 ++++++++++++------ .../controllers/chat/messenger/messenger.ts | 14 +- .../src/amazonqFeatureDev/session/session.ts | 125 +++++++++- .../amazonqFeatureDev/session/sessionState.ts | 5 +- packages/core/src/amazonqFeatureDev/types.ts | 8 + .../views/actions/uiMessageListener.ts | 10 + .../views/connector/connector.ts | 39 +++- .../core/src/test/amazonq/common/diff.test.ts | 36 ++- .../controllers/chat/controller.test.ts | 31 ++- .../core/src/test/amazonqFeatureDev/utils.ts | 1 + 27 files changed, 698 insertions(+), 142 deletions(-) create mode 100644 packages/amazonq/.changes/next-release/Feature-d18cffcd-fd30-4936-9586-587b0ba88ec9.json diff --git a/package-lock.json b/package-lock.json index 86f2cabca1a..335f510f9ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5171,13 +5171,15 @@ } }, "node_modules/@aws/mynah-ui": { - "version": "4.15.11", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@aws/mynah-ui/-/mynah-ui-4.18.0.tgz", + "integrity": "sha512-oa26D9QtGxw9kZnDBq7OrVEPdBvmwK+MjlTIjW8L/TnlDTIYL3SBhU87iQCwkCRxWXckrjiSvW6Rexc2oasgvw==", "hasInstallScript": true, "license": "Apache License 2.0", "dependencies": { "escape-html": "^1.0.3", "just-clone": "^6.2.0", - "marked": "^12.0.2", + "marked": "^14.1.0", "prismjs": "1.29.0", "sanitize-html": "^2.12.1", "unescape-html": "^1.1.0" @@ -5192,7 +5194,9 @@ } }, "node_modules/@aws/mynah-ui/node_modules/marked": { - "version": "12.0.2", + "version": "14.1.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.1.4.tgz", + "integrity": "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -20030,7 +20034,7 @@ "@aws-sdk/property-provider": "3.46.0", "@aws-sdk/smithy-client": "^3.46.0", "@aws-sdk/util-arn-parser": "^3.46.0", - "@aws/mynah-ui": "^4.15.11", + "@aws/mynah-ui": "^4.18.0", "@gerhobbelt/gitignore-parser": "^0.2.0-9", "@iarna/toml": "^2.2.5", "@smithy/middleware-retry": "^2.3.1", diff --git a/packages/amazonq/.changes/next-release/Feature-d18cffcd-fd30-4936-9586-587b0ba88ec9.json b/packages/amazonq/.changes/next-release/Feature-d18cffcd-fd30-4936-9586-587b0ba88ec9.json new file mode 100644 index 00000000000..ef65371cbb2 --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-d18cffcd-fd30-4936-9586-587b0ba88ec9.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "Amazon Q /dev: Add an action to accept individual files" +} diff --git a/packages/amazonq/test/e2e/amazonq/featureDev.test.ts b/packages/amazonq/test/e2e/amazonq/featureDev.test.ts index 9cd87003f36..a96da96c199 100644 --- a/packages/amazonq/test/e2e/amazonq/featureDev.test.ts +++ b/packages/amazonq/test/e2e/amazonq/featureDev.test.ts @@ -17,7 +17,8 @@ describe('Amazon Q Feature Dev', function () { let tab: Messenger const prompt = 'Add blank.txt file with empty content' - const codegenApproachPrompt = prompt + ' and add a readme that describes the changes' + const codegenApproachPrompt = `${prompt} and add a readme that describes the changes` + const fileLevelAcceptPrompt = `${prompt} and add a license, and a contributing file` const tooManyRequestsWaitTime = 100000 function waitForButtons(buttons: FollowUpTypes[]) { @@ -50,6 +51,14 @@ describe('Amazon Q Feature Dev', function () { ) } + async function clickActionButton(filePath: string, actionName: string) { + tab.clickFileActionButton(filePath, actionName) + await tab.waitForEvent(() => !tab.hasAction(filePath, actionName), { + waitIntervalInMs: 500, + waitTimeoutInMs: 600000, + }) + } + /** * Wait for the original request to finish. * If the response has a retry button or encountered a guardrails error, continue retrying @@ -216,4 +225,120 @@ describe('Amazon Q Feature Dev', function () { await waitForButtons([FollowUpTypes.NewTask, FollowUpTypes.CloseSession]) }) }) + + describe('file-level accepts', async () => { + beforeEach(async function () { + tab.addChatMessage({ command: '/dev', prompt: fileLevelAcceptPrompt }) + await retryIfRequired( + async () => { + await tab.waitForChatFinishesLoading() + }, + () => { + tab.addChatMessage({ prompt }) + } + ) + await retryIfRequired(async () => { + await Promise.any([ + waitForButtons([FollowUpTypes.InsertCode, FollowUpTypes.ProvideFeedbackAndRegenerateCode]), + waitForButtons([FollowUpTypes.Retry]), + ]) + }) + }) + + describe('fileList', async () => { + it('has both accept-change and reject-change action buttons for file', async () => { + const filePath = tab.getFilePaths()[0] + assert.ok(tab.getActionsByFilePath(filePath).length === 2) + assert.ok(tab.hasAction(filePath, 'accept-change')) + assert.ok(tab.hasAction(filePath, 'reject-change')) + }) + + it('has only revert-rejection action button for rejected file', async () => { + const filePath = tab.getFilePaths()[0] + await clickActionButton(filePath, 'reject-change') + + assert.ok(tab.getActionsByFilePath(filePath).length === 1) + assert.ok(tab.hasAction(filePath, 'revert-rejection')) + }) + + it('does not have any of the action buttons for accepted file', async () => { + const filePath = tab.getFilePaths()[0] + await clickActionButton(filePath, 'accept-change') + + assert.ok(tab.getActionsByFilePath(filePath).length === 0) + }) + + it('disables all action buttons when new task is clicked', async () => { + tab.clickButton(FollowUpTypes.InsertCode) + await waitForButtons([FollowUpTypes.NewTask, FollowUpTypes.CloseSession]) + tab.clickButton(FollowUpTypes.NewTask) + await waitForText('What new task would you like to work on?') + + const filePaths = tab.getFilePaths() + for (const filePath of filePaths) { + assert.ok(tab.getActionsByFilePath(filePath).length === 0) + } + }) + + it('disables all action buttons when close session is clicked', async () => { + tab.clickButton(FollowUpTypes.InsertCode) + await waitForButtons([FollowUpTypes.NewTask, FollowUpTypes.CloseSession]) + tab.clickButton(FollowUpTypes.CloseSession) + await waitForText( + "Okay, I've ended this chat session. You can open a new tab to chat or start another workflow." + ) + + const filePaths = tab.getFilePaths() + for (const filePath of filePaths) { + assert.ok(tab.getActionsByFilePath(filePath).length === 0) + } + }) + }) + + describe('accept button', async () => { + describe('button text', async () => { + it('shows "Accept all changes" when no files are accepted or rejected, and "Accept remaining changes" otherwise', async () => { + let insertCodeButton = tab.getFollowUpButton(FollowUpTypes.InsertCode) + assert.ok(insertCodeButton.pillText === 'Accept all changes') + + const filePath = tab.getFilePaths()[0] + await clickActionButton(filePath, 'reject-change') + + insertCodeButton = tab.getFollowUpButton(FollowUpTypes.InsertCode) + assert.ok(insertCodeButton.pillText === 'Accept remaining changes') + + await clickActionButton(filePath, 'revert-rejection') + + insertCodeButton = tab.getFollowUpButton(FollowUpTypes.InsertCode) + assert.ok(insertCodeButton.pillText === 'Accept all changes') + + await clickActionButton(filePath, 'accept-change') + + insertCodeButton = tab.getFollowUpButton(FollowUpTypes.InsertCode) + assert.ok(insertCodeButton.pillText === 'Accept remaining changes') + }) + + it('shows "Continue" when all files are either accepted or rejected, with at least one of them rejected', async () => { + const filePaths = tab.getFilePaths() + for (const filePath of filePaths) { + await clickActionButton(filePath, 'reject-change') + } + + const insertCodeButton = tab.getFollowUpButton(FollowUpTypes.InsertCode) + assert.ok(insertCodeButton.pillText === 'Continue') + }) + }) + + it('disappears and automatically moves on to the next step when all changes are accepted', async () => { + const filePaths = tab.getFilePaths() + for (const filePath of filePaths) { + await clickActionButton(filePath, 'accept-change') + } + await waitForButtons([FollowUpTypes.NewTask, FollowUpTypes.CloseSession]) + + assert.ok(tab.hasButton(FollowUpTypes.InsertCode) === false) + assert.ok(tab.hasButton(FollowUpTypes.ProvideFeedbackAndRegenerateCode) === false) + }) + }) + }) }) diff --git a/packages/amazonq/test/e2e/amazonq/framework/messenger.ts b/packages/amazonq/test/e2e/amazonq/framework/messenger.ts index ce612bd41c5..28b34aa3bdb 100644 --- a/packages/amazonq/test/e2e/amazonq/framework/messenger.ts +++ b/packages/amazonq/test/e2e/amazonq/framework/messenger.ts @@ -59,6 +59,14 @@ export class Messenger { this.mynahUIProps.onFollowUpClicked(this.tabID, lastChatItem?.messageId ?? '', option[0]) } + clickFileActionButton(filePath: string, actionName: string) { + if (!this.mynahUIProps.onFileActionClick) { + assert.fail('onFileActionClick must be defined to use it in the tests') + } + + this.mynahUIProps.onFileActionClick(this.tabID, this.getFileListMessageId(), filePath, actionName) + } + findCommand(command: string) { return this.getCommands() .map((groups) => groups.commands) @@ -78,6 +86,52 @@ export class Messenger { return this.getStore().promptInputPlaceholder } + getFollowUpButton(type: FollowUpTypes) { + const followUpButton = this.getChatItems() + .pop() + ?.followUp?.options?.find((action) => action.type === type) + if (!followUpButton) { + assert.fail(`Could not find follow up button with type ${type}`) + } + return followUpButton + } + + getFileList() { + const chatItems = this.getChatItems() + const fileList = chatItems.find((item) => 'fileList' in item) + if (!fileList) { + assert.fail('Could not find file list') + } + return fileList + } + + getFileListMessageId() { + const fileList = this.getFileList() + const messageId = fileList?.messageId + if (!messageId) { + assert.fail('Could not find file list message id') + } + return messageId + } + + getFilePaths() { + const fileList = this.getFileList() + const filePaths = fileList?.fileList?.filePaths + if (!filePaths) { + assert.fail('Could not find file paths') + } + if (filePaths.length === 0) { + assert.fail('File paths list is empty') + } + return filePaths + } + + getActionsByFilePath(filePath: string) { + const fileList = this.getFileList() + const actions = fileList?.fileList?.actions + return actions?.[filePath] ?? [] + } + hasButton(type: FollowUpTypes) { return ( this.getChatItems() @@ -87,6 +141,10 @@ export class Messenger { ) } + hasAction(filePath: string, actionName: string) { + return this.getActionsByFilePath(filePath).some((action) => action.name === actionName) + } + async waitForChatFinishesLoading() { return this.waitForEvent(() => this.getStore().loadingChat === false || this.hasButton(FollowUpTypes.Retry)) } diff --git a/packages/amazonq/test/unit/amazonqFeatureDev/session/session.test.ts b/packages/amazonq/test/unit/amazonqFeatureDev/session/session.test.ts index f2a08348d23..1d1f0bcd835 100644 --- a/packages/amazonq/test/unit/amazonqFeatureDev/session/session.test.ts +++ b/packages/amazonq/test/unit/amazonqFeatureDev/session/session.test.ts @@ -83,6 +83,7 @@ describe('session', () => { rejected: false, virtualMemoryUri: uri, workspaceFolder: controllerSetup.workspaceFolder, + changeApplied: false, }, { zipFilePath: 'rejectedFile.js', @@ -91,6 +92,7 @@ describe('session', () => { rejected: true, virtualMemoryUri: generateVirtualMemoryUri(uploadID, 'rejectedFile.js'), workspaceFolder: controllerSetup.workspaceFolder, + changeApplied: false, }, ], [], diff --git a/packages/core/package.json b/packages/core/package.json index f74f4b8482e..51592f42643 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -470,7 +470,7 @@ "@aws-sdk/property-provider": "3.46.0", "@aws-sdk/smithy-client": "^3.46.0", "@aws-sdk/util-arn-parser": "^3.46.0", - "@aws/mynah-ui": "^4.15.11", + "@aws/mynah-ui": "^4.18.0", "@gerhobbelt/gitignore-parser": "^0.2.0-9", "@iarna/toml": "^2.2.5", "@smithy/middleware-retry": "^2.3.1", diff --git a/packages/core/package.nls.json b/packages/core/package.nls.json index bc3d2182452..dbccb19cc72 100644 --- a/packages/core/package.nls.json +++ b/packages/core/package.nls.json @@ -312,6 +312,9 @@ "AWS.amazonq.featureDev.pillText.generatingCode": "Generating code...", "AWS.amazonq.featureDev.pillText.requestingChanges": "Requesting changes ...", "AWS.amazonq.featureDev.pillText.insertCode": "Accept code", + "AWS.amazonq.featureDev.pillText.continue": "Continue", + "AWS.amazonq.featureDev.pillText.acceptAllChanges": "Accept all changes", + "AWS.amazonq.featureDev.pillText.acceptRemainingChanges": "Accept remaining changes", "AWS.amazonq.featureDev.pillText.stoppingCodeGeneration": "Stopping code generation...", "AWS.amazonq.featureDev.pillText.sendFeedback": "Send feedback", "AWS.amazonq.featureDev.pillText.selectFiles": "Select files for context", diff --git a/packages/core/src/amazonq/commons/diff.ts b/packages/core/src/amazonq/commons/diff.ts index 56705ed0441..8976e2e9b1f 100644 --- a/packages/core/src/amazonq/commons/diff.ts +++ b/packages/core/src/amazonq/commons/diff.ts @@ -13,8 +13,9 @@ export async function openDiff(leftPath: string, rightPath: string, tabId: strin } export async function openDeletedDiff(filePath: string, name: string, tabId: string) { - const fileUri = await getOriginalFileUri(filePath, tabId) - await vscode.commands.executeCommand('vscode.open', fileUri, {}, `${name} (Deleted)`) + const left = await getOriginalFileUri(filePath, tabId) + const right = createAmazonQUri('empty', tabId) + await vscode.commands.executeCommand('vscode.diff', left, right, `${name} (Deleted)`) } export async function getOriginalFileUri(fullPath: string, tabId: string) { @@ -32,3 +33,11 @@ export function createAmazonQUri(path: string, tabId: string) { // TODO change the featureDevScheme to a more general amazon q scheme return vscode.Uri.from({ scheme: featureDevScheme, path, query: `tabID=${tabId}` }) } + +export async function openFile(path: string) { + if (!(await fs.exists(path))) { + return + } + const fileUri = vscode.Uri.file(path) + await vscode.commands.executeCommand('vscode.diff', fileUri, fileUri) +} diff --git a/packages/core/src/amazonq/index.ts b/packages/core/src/amazonq/index.ts index a08748b2b98..fcaa0ee56c1 100644 --- a/packages/core/src/amazonq/index.ts +++ b/packages/core/src/amazonq/index.ts @@ -27,7 +27,14 @@ export { amazonQHelpUrl } from '../shared/constants' export { listCodeWhispererCommandsWalkthrough } from '../codewhisperer/ui/statusBarMenu' export { focusAmazonQPanel, focusAmazonQPanelKeybinding } from '../codewhispererChat/commands/registerCommands' export { TryChatCodeLensProvider, tryChatCodeLensCommand } from '../codewhispererChat/editor/codelens' -export { createAmazonQUri, openDiff, openDeletedDiff, getOriginalFileUri, getFileDiffUris } from './commons/diff' +export { + createAmazonQUri, + openDiff, + openDeletedDiff, + getOriginalFileUri, + getFileDiffUris, + openFile, +} from './commons/diff' export { CodeReference } from '../codewhispererChat/view/connector/connector' export { AuthMessageDataMap, AuthFollowUpType } from './auth/model' export { extractAuthFollowUp } from './util/authUtils' diff --git a/packages/core/src/amazonq/webview/ui/apps/featureDevChatConnector.ts b/packages/core/src/amazonq/webview/ui/apps/featureDevChatConnector.ts index ddc6c68d99c..83dd25b4889 100644 --- a/packages/core/src/amazonq/webview/ui/apps/featureDevChatConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/featureDevChatConnector.ts @@ -17,12 +17,14 @@ export interface ConnectorProps extends BaseConnectorProps { messageId: string | undefined, enableStopAction: boolean ) => void + onChatAnswerUpdated?: (tabID: string, message: ChatItem) => void sendFeedback?: (tabId: string, feedbackPayload: FeedbackPayload) => void | undefined onFileComponentUpdate: ( tabID: string, filePaths: DiffTreeFileInfo[], deletedFiles: DiffTreeFileInfo[], - messageId: string + messageId: string, + disableFileActions: boolean ) => void onFileActionClick: (tabID: string, messageId: string, filePath: string, actionName: string) => void onUpdatePlaceholder: (tabID: string, newPlaceholder: string) => void @@ -33,6 +35,7 @@ export interface ConnectorProps extends BaseConnectorProps { export class Connector extends BaseConnector { private readonly onFileComponentUpdate + private readonly onChatAnswerUpdated private readonly onAsyncEventProgress private readonly updatePlaceholder private readonly chatInputEnabled @@ -51,6 +54,7 @@ export class Connector extends BaseConnector { this.chatInputEnabled = props.onChatInputEnabled this.onUpdateAuthentication = props.onUpdateAuthentication this.onNewTab = props.onNewTab + this.onChatAnswerUpdated = props.onChatAnswerUpdated } onOpenDiff = (tabID: string, filePath: string, deleted: boolean, messageId?: string): void => { @@ -74,32 +78,49 @@ export class Connector extends BaseConnector { }) } + private createAnswer = (messageData: any): ChatItem => { + return { + type: messageData.messageType, + body: messageData.message ?? undefined, + messageId: messageData.messageId ?? messageData.messageID ?? messageData.triggerID ?? '', + relatedContent: undefined, + canBeVoted: messageData.canBeVoted ?? undefined, + snapToTop: messageData.snapToTop ?? undefined, + followUp: + messageData.followUps !== undefined && Array.isArray(messageData.followUps) + ? { + text: + messageData.messageType === ChatItemType.SYSTEM_PROMPT || + messageData.followUps.length === 0 + ? '' + : 'Please follow up with one of these', + options: messageData.followUps, + } + : undefined, + } + } + private processChatMessage = async (messageData: any): Promise => { if (this.onChatAnswerReceived !== undefined) { - const answer: ChatItem = { - type: messageData.messageType, - body: messageData.message ?? undefined, - messageId: messageData.messageID ?? messageData.triggerID ?? '', - relatedContent: undefined, - canBeVoted: messageData.canBeVoted, - snapToTop: messageData.snapToTop, - followUp: - messageData.followUps !== undefined && messageData.followUps.length > 0 - ? { - text: - messageData.messageType === ChatItemType.SYSTEM_PROMPT - ? '' - : 'Please follow up with one of these', - options: messageData.followUps, - } - : undefined, - } + const answer = this.createAnswer(messageData) this.onChatAnswerReceived(messageData.tabID, answer, messageData) } } private processCodeResultMessage = async (messageData: any): Promise => { if (this.onChatAnswerReceived !== undefined) { + const messageId = + messageData.codeGenerationId ?? + messageData.messageId ?? + messageData.messageID ?? + messageData.triggerID ?? + messageData.conversationID + this.sendMessageToExtension({ + tabID: messageData.tabID, + command: 'store-code-result-message-id', + messageId, + tabType: 'featuredev', + }) const actions = getActions([...messageData.filePaths, ...messageData.deletedFiles]) const answer: ChatItem = { type: ChatItemType.ANSWER, @@ -107,12 +128,7 @@ export class Connector extends BaseConnector { followUp: undefined, canBeVoted: true, codeReference: messageData.references, - // TODO get the backend to store a message id in addition to conversationID - messageId: - messageData.codeGenerationId ?? - messageData.messageID ?? - messageData.triggerID ?? - messageData.conversationID, + messageId, fileList: { rootFolderTitle: 'Changes', filePaths: messageData.filePaths.map((f: DiffTreeFileInfo) => f.zipFilePath), @@ -131,10 +147,16 @@ export class Connector extends BaseConnector { messageData.tabID, messageData.filePaths, messageData.deletedFiles, - messageData.messageId + messageData.messageId, + messageData.disableFileActions ) return } + if (messageData.type === 'updateChatAnswer') { + const answer = this.createAnswer(messageData) + this.onChatAnswerUpdated?.(messageData.tabID, answer) + return + } if (messageData.type === 'chatMessage') { await this.processChatMessage(messageData) diff --git a/packages/core/src/amazonq/webview/ui/commands.ts b/packages/core/src/amazonq/webview/ui/commands.ts index d502cba861d..94e1fddb251 100644 --- a/packages/core/src/amazonq/webview/ui/commands.ts +++ b/packages/core/src/amazonq/webview/ui/commands.ts @@ -35,5 +35,6 @@ type MessageCommand = | 'open-settings' | 'start-chat-message-telemetry' | 'stop-chat-message-telemetry' + | 'store-code-result-message-id' 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 376ca1ca42c..9f265fbfe7e 100644 --- a/packages/core/src/amazonq/webview/ui/connector.ts +++ b/packages/core/src/amazonq/webview/ui/connector.ts @@ -70,7 +70,8 @@ export interface ConnectorProps { tabID: string, filePaths: DiffTreeFileInfo[], deletedFiles: DiffTreeFileInfo[], - messageId: string + messageId: string, + disableFileActions: boolean ) => void onUpdatePlaceholder: (tabID: string, newPlaceholder: string) => void onChatInputEnabled: (tabID: string, enabled: boolean) => void diff --git a/packages/core/src/amazonq/webview/ui/diffTree/actions.ts b/packages/core/src/amazonq/webview/ui/diffTree/actions.ts index 2ad507cf9fe..93fa02b37dd 100644 --- a/packages/core/src/amazonq/webview/ui/diffTree/actions.ts +++ b/packages/core/src/amazonq/webview/ui/diffTree/actions.ts @@ -6,14 +6,21 @@ import { MynahIcons } from '@aws/mynah-ui' import { FileNodeAction, TreeNodeDetails } from '@aws/mynah-ui/dist/static' import { DiffTreeFileInfo } from './types' +import { uiComponentsTexts } from '../texts/constants' export function getDetails(filePaths: DiffTreeFileInfo[]): Record { const details: Record = {} for (const filePath of filePaths) { - if (filePath.rejected) { + if (filePath.changeApplied) { + details[filePath.relativePath] = { + status: 'success', + label: uiComponentsTexts.changeAccepted, + icon: MynahIcons.OK, + } + } else if (filePath.rejected) { details[filePath.relativePath] = { status: 'error', - label: 'File rejected', + label: uiComponentsTexts.changeRejected, icon: MynahIcons.CANCEL_CIRCLE, } } @@ -24,23 +31,32 @@ export function getDetails(filePaths: DiffTreeFileInfo[]): Record { const actions: Record = {} for (const filePath of filePaths) { + if (filePath.changeApplied) { + continue + } switch (filePath.rejected) { case true: actions[filePath.relativePath] = [ { icon: MynahIcons.REVERT, name: 'revert-rejection', - description: 'Revert rejection', + description: uiComponentsTexts.revertRejection, }, ] break case false: actions[filePath.relativePath] = [ + { + icon: MynahIcons.OK, + status: 'success', + name: 'accept-change', + description: uiComponentsTexts.acceptChange, + }, { icon: MynahIcons.CANCEL_CIRCLE, status: 'error', name: 'reject-change', - description: 'Reject change', + description: uiComponentsTexts.rejectChange, }, ] break diff --git a/packages/core/src/amazonq/webview/ui/diffTree/types.ts b/packages/core/src/amazonq/webview/ui/diffTree/types.ts index 05864541e0d..ce095b11455 100644 --- a/packages/core/src/amazonq/webview/ui/diffTree/types.ts +++ b/packages/core/src/amazonq/webview/ui/diffTree/types.ts @@ -7,4 +7,5 @@ export type DiffTreeFileInfo = { zipFilePath: string relativePath: string rejected: boolean + changeApplied: boolean } diff --git a/packages/core/src/amazonq/webview/ui/main.ts b/packages/core/src/amazonq/webview/ui/main.ts index db9035b2517..d84af367291 100644 --- a/packages/core/src/amazonq/webview/ui/main.ts +++ b/packages/core/src/amazonq/webview/ui/main.ts @@ -238,12 +238,14 @@ export const createMynahUI = ( mynahUI.updateChatAnswerWithMessageId(tabID, item.messageId, { ...(item.body !== undefined ? { body: item.body } : {}), ...(item.buttons !== undefined ? { buttons: item.buttons } : {}), + ...(item.followUp !== undefined ? { followUp: item.followUp } : {}), }) } else { mynahUI.updateLastChatAnswer(tabID, { ...(item.body !== undefined ? { body: item.body } : {}), ...(item.buttons !== undefined ? { buttons: item.buttons } : {}), - } as ChatItem) + ...(item.followUp !== undefined ? { followUp: item.followUp } : {}), + }) } }, onChatAnswerReceived: (tabID: string, item: CWCChatItem, messageData: any) => { @@ -348,7 +350,8 @@ export const createMynahUI = ( tabID: string, filePaths: DiffTreeFileInfo[], deletedFiles: DiffTreeFileInfo[], - messageId: string + messageId: string, + disableFileActions: boolean ) => { const updateWith: Partial = { type: ChatItemType.ANSWER, @@ -356,8 +359,8 @@ export const createMynahUI = ( rootFolderTitle: 'Changes', filePaths: filePaths.map((i) => i.zipFilePath), deletedFiles: deletedFiles.map((i) => i.zipFilePath), - details: getDetails(filePaths), - actions: getActions([...filePaths, ...deletedFiles]), + details: getDetails([...filePaths, ...deletedFiles]), + actions: disableFileActions ? undefined : getActions([...filePaths, ...deletedFiles]), }, } mynahUI.updateChatAnswerWithMessageId(tabID, messageId, updateWith) diff --git a/packages/core/src/amazonq/webview/ui/texts/constants.ts b/packages/core/src/amazonq/webview/ui/texts/constants.ts index 5ac1f34fda9..673057666b4 100644 --- a/packages/core/src/amazonq/webview/ui/texts/constants.ts +++ b/packages/core/src/amazonq/webview/ui/texts/constants.ts @@ -24,6 +24,11 @@ export const uiComponentsTexts = { noMoreTabsTooltip: 'You can only open ten conversation tabs at a time.', codeSuggestionWithReferenceTitle: 'Some suggestions contain code with references.', spinnerText: 'Generating your answer...', + changeAccepted: 'Change accepted', + changeRejected: 'Change rejected', + acceptChange: 'Accept change', + rejectChange: 'Reject change', + revertRejection: 'Revert rejection', } export const userGuideURL = 'https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/software-dev.html' diff --git a/packages/core/src/amazonqFeatureDev/app.ts b/packages/core/src/amazonqFeatureDev/app.ts index 3e5a103011a..3aa6b06356f 100644 --- a/packages/core/src/amazonqFeatureDev/app.ts +++ b/packages/core/src/amazonqFeatureDev/app.ts @@ -34,6 +34,7 @@ export function init(appContext: AmazonQAppInitContext) { processResponseBodyLinkClick: new vscode.EventEmitter(), insertCodeAtPositionClicked: new vscode.EventEmitter(), fileClicked: new vscode.EventEmitter(), + storeCodeResultMessageId: new vscode.EventEmitter(), } const messenger = new Messenger(new AppToWebViewMessageDispatcher(appContext.getAppsToWebViewMessagePublisher())) diff --git a/packages/core/src/amazonqFeatureDev/controllers/chat/controller.ts b/packages/core/src/amazonqFeatureDev/controllers/chat/controller.ts index aa300b9ddba..91f941e519c 100644 --- a/packages/core/src/amazonqFeatureDev/controllers/chat/controller.ts +++ b/packages/core/src/amazonqFeatureDev/controllers/chat/controller.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ChatItemAction, MynahIcons } from '@aws/mynah-ui' +import { MynahIcons } from '@aws/mynah-ui' import * as path from 'path' import * as vscode from 'vscode' import { EventEmitter } from 'vscode' @@ -12,6 +12,8 @@ import { createSingleFileDialog } from '../../../shared/ui/common/openDialog' import { CodeIterationLimitError, ContentLengthError, + createUserFacingErrorMessage, + denyListedErrors, FeatureDevServiceError, MonthlyConversationLimitError, NoChangeRequiredException, @@ -24,14 +26,12 @@ import { UserMessageNotFoundError, WorkspaceFolderNotFoundError, ZipFileError, - createUserFacingErrorMessage, - denyListedErrors, } from '../../errors' import { codeGenRetryLimit, defaultRetryLimit } from '../../limits' import { Session } from '../../session/session' import { featureName } from '../../constants' import { ChatSessionStorage } from '../../storages/chatSession' -import { DevPhase, FollowUpTypes, SessionStatePhase } from '../../types' +import { DeletedFileInfo, DevPhase, FollowUpTypes, type NewFileInfo } from '../../types' import { Messenger } from './messenger/messenger' import { AuthUtil } from '../../../codewhisperer/util/authUtil' import { AuthController } from '../../../amazonq/auth/controller' @@ -43,9 +43,10 @@ import { openUrl } from '../../../shared/utilities/vsCodeUtils' import { getPathsFromZipFilePath } from '../../util/files' import { examples, messageWithConversationId } from '../../userFacingText' import { getWorkspaceFoldersByPrefixes } from '../../../shared/utilities/workspaceUtils' -import { openDeletedDiff, openDiff } from '../../../amazonq/commons/diff' +import { openDeletedDiff, openDiff, openFile } from '../../../amazonq/commons/diff' import { i18n } from '../../../shared/i18n-helper' import globals from '../../../shared/extensionGlobals' +import { randomUUID } from '../../../shared' export const TotalSteps = 3 @@ -62,6 +63,7 @@ export interface ChatControllerEventEmitters { readonly processResponseBodyLinkClick: EventEmitter readonly insertCodeAtPositionClicked: EventEmitter readonly fileClicked: EventEmitter + readonly storeCodeResultMessageId: EventEmitter } type OpenDiffMessage = { @@ -79,6 +81,12 @@ type fileClickedMessage = { filePath: string actionName: string } + +type StoreMessageIdMessage = { + tabID: string + messageId: string +} + export class FeatureDevController { private readonly messenger: Messenger private readonly sessionStorage: ChatSessionStorage @@ -173,6 +181,9 @@ export class FeatureDevController { this.chatControllerMessageListeners.fileClicked.event(async (data) => { return await this.fileClicked(data) }) + this.chatControllerMessageListeners.storeCodeResultMessageId.event(async (data) => { + return await this.storeCodeResultMessageId(data) + }) } private async processChatItemVotedMessage(tabId: string, vote: string) { @@ -278,7 +289,11 @@ export class FeatureDevController { tabID: message.tabID, followUps: [ { - pillText: i18n('AWS.amazonq.featureDev.pillText.insertCode'), + pillText: + session?.getInsertCodePillText([ + ...(session?.state.filePaths ?? []), + ...(session?.state.deletedFiles ?? []), + ]) ?? i18n('AWS.amazonq.featureDev.pillText.acceptAllChanges'), type: FollowUpTypes.InsertCode, icon: 'ok' as MynahIcons, status: 'success', @@ -354,6 +369,7 @@ export class FeatureDevController { getLogger().debug(`${featureName}: Processing message: ${message.message}`) session = await this.sessionStorage.getSession(message.tabID) + await session.disableFileList() const authState = await AuthUtil.instance.getChatAuthState() if (authState.amazonQ !== 'connected') { await this.messenger.sendAuthNeededExceptionMessage(authState, message.tabID) @@ -452,19 +468,27 @@ export class FeatureDevController { }) } - this.messenger.sendAnswer({ - message: undefined, - type: 'system-prompt', - followUps: this.getFollowUpOptions(session?.state.phase), - tabID: tabID, - }) + if (session?.state.phase === DevPhase.CODEGEN) { + const messageId = randomUUID() + session.updateAcceptCodeMessageId(messageId) + session.updateAcceptCodeTelemetrySent(false) + // need to add the followUps with an extra update here, or it will double-render them + this.messenger.sendAnswer({ + message: undefined, + type: 'system-prompt', + followUps: [], + tabID: tabID, + messageId, + }) + await session.updateChatAnswer(tabID, i18n('AWS.amazonq.featureDev.pillText.acceptAllChanges')) + } this.messenger.sendUpdatePlaceholder(tabID, i18n('AWS.amazonq.featureDev.pillText.selectOption')) } finally { // Finish processing the event if (session?.state?.tokenSource?.token.isCancellationRequested) { this.workOnNewTask( - session, + session.tabID, session.state.codeGenerationRemainingIterationCount || TotalSteps - (session.state?.currentIteration || 0), session.state.codeGenerationTotalIterationCount || TotalSteps, @@ -491,8 +515,18 @@ export class FeatureDevController { } } } + + private sendUpdateCodeMessage(tabID: string) { + this.messenger.sendAnswer({ + type: 'answer', + tabID, + message: i18n('AWS.amazonq.featureDev.answer.updateCode'), + canBeVoted: true, + }) + } + private workOnNewTask( - message: any, + tabID: string, remainingIterations: number = 0, totalIterations?: number, isStoppedGeneration: boolean = false @@ -504,14 +538,14 @@ export class FeatureDevController { ? "I stopped generating your code. You don't have more iterations left, however, you can start a new session." : `I stopped generating your code. If you want to continue working on this task, provide another description. You have ${remainingIterations} out of ${totalIterations} code generations left.`, type: 'answer-part', - tabID: message.tabID, + tabID, }) } if ((remainingIterations <= 0 && isStoppedGeneration) || !isStoppedGeneration) { this.messenger.sendAnswer({ type: 'system-prompt', - tabID: message.tabID, + tabID, followUps: [ { pillText: i18n('AWS.amazonq.featureDev.pillText.newTask'), @@ -525,17 +559,14 @@ export class FeatureDevController { }, ], }) - this.messenger.sendChatInputEnabled(message.tabID, false) - this.messenger.sendUpdatePlaceholder(message.tabID, i18n('AWS.amazonq.featureDev.pillText.selectOption')) + this.messenger.sendChatInputEnabled(tabID, false) + this.messenger.sendUpdatePlaceholder(tabID, i18n('AWS.amazonq.featureDev.pillText.selectOption')) return } // Ensure that chat input is enabled so that they can provide additional iterations if they choose - this.messenger.sendChatInputEnabled(message.tabID, true) - this.messenger.sendUpdatePlaceholder( - message.tabID, - i18n('AWS.amazonq.featureDev.placeholder.additionalImprovements') - ) + this.messenger.sendChatInputEnabled(tabID, true) + this.messenger.sendUpdatePlaceholder(tabID, i18n('AWS.amazonq.featureDev.placeholder.additionalImprovements')) } // TODO add type private async insertCode(message: any) { @@ -545,29 +576,20 @@ export class FeatureDevController { const acceptedFiles = (paths?: { rejected: boolean }[]) => (paths || []).filter((i) => !i.rejected).length - const amazonqNumberOfFilesAccepted = - acceptedFiles(session.state.filePaths) + acceptedFiles(session.state.deletedFiles) + const filesAccepted = acceptedFiles(session.state.filePaths) + acceptedFiles(session.state.deletedFiles) - telemetry.amazonq_isAcceptedCodeChanges.emit({ - credentialStartUrl: AuthUtil.instance.startUrl, - amazonqConversationId: session.conversationId, - amazonqNumberOfFilesAccepted, - enabled: true, - result: 'Succeeded', - }) - await session.insertChanges() - this.messenger.sendAnswer({ - type: 'answer', - tabID: message.tabID, - message: i18n('AWS.amazonq.featureDev.answer.updateCode'), - canBeVoted: true, - }) + this.sendAcceptCodeTelemetry(session, filesAccepted) - this.workOnNewTask( - message, - session.state.codeGenerationRemainingIterationCount, - session.state.codeGenerationTotalIterationCount - ) + await session.insertChanges() + if (session.acceptCodeMessageId) { + this.sendUpdateCodeMessage(message.tabID) + this.workOnNewTask( + message.tabID, + session.state.codeGenerationRemainingIterationCount, + session.state.codeGenerationTotalIterationCount + ) + await this.clearAcceptCodeMessageId(message.tabID) + } } catch (err: any) { this.messenger.sendErrorMessage( createUserFacingErrorMessage(`Failed to insert code changes: ${err.message}`), @@ -627,28 +649,6 @@ export class FeatureDevController { } } - private getFollowUpOptions(phase: SessionStatePhase | undefined): ChatItemAction[] { - switch (phase) { - case DevPhase.CODEGEN: - return [ - { - pillText: i18n('AWS.amazonq.featureDev.pillText.insertCode'), - type: FollowUpTypes.InsertCode, - icon: 'ok' as MynahIcons, - status: 'success', - }, - { - pillText: i18n('AWS.amazonq.featureDev.pillText.provideFeedback'), - type: FollowUpTypes.ProvideFeedbackAndRegenerateCode, - icon: 'refresh' as MynahIcons, - status: 'info', - }, - ] - default: - return [] - } - } - private async modifyDefaultSourceFolder(message: any) { const session = await this.sessionStorage.getSession(message.tabID) @@ -737,26 +737,67 @@ export class FeatureDevController { const tabId: string = message.tabID const messageId = message.messageId const filePathToUpdate: string = message.filePath + const action = message.actionName const session = await this.sessionStorage.getSession(tabId) const filePathIndex = (session.state.filePaths ?? []).findIndex((obj) => obj.relativePath === filePathToUpdate) - if (filePathIndex !== -1 && session.state.filePaths) { - session.state.filePaths[filePathIndex].rejected = !session.state.filePaths[filePathIndex].rejected - } const deletedFilePathIndex = (session.state.deletedFiles ?? []).findIndex( (obj) => obj.relativePath === filePathToUpdate ) + + if (filePathIndex !== -1 && session.state.filePaths) { + if (action === 'accept-change') { + this.sendAcceptCodeTelemetry(session, 1) + await session.insertNewFiles([session.state.filePaths[filePathIndex]]) + await session.insertCodeReferenceLogs(session.state.references ?? []) + await this.openFile(session.state.filePaths[filePathIndex]) + } else { + session.state.filePaths[filePathIndex].rejected = !session.state.filePaths[filePathIndex].rejected + } + } if (deletedFilePathIndex !== -1 && session.state.deletedFiles) { - session.state.deletedFiles[deletedFilePathIndex].rejected = - !session.state.deletedFiles[deletedFilePathIndex].rejected + if (action === 'accept-change') { + this.sendAcceptCodeTelemetry(session, 1) + await session.applyDeleteFiles([session.state.deletedFiles[deletedFilePathIndex]]) + await session.insertCodeReferenceLogs(session.state.references ?? []) + } else { + session.state.deletedFiles[deletedFilePathIndex].rejected = + !session.state.deletedFiles[deletedFilePathIndex].rejected + } } - await session.updateFilesPaths( - tabId, - session.state.filePaths ?? [], - session.state.deletedFiles ?? [], - messageId - ) + await session.updateFilesPaths({ + tabID: tabId, + filePaths: session.state.filePaths ?? [], + deletedFiles: session.state.deletedFiles ?? [], + messageId, + }) + + if (session.acceptCodeMessageId) { + const allFilePathsAccepted = session.state.filePaths?.every( + (filePath: NewFileInfo) => !filePath.rejected && filePath.changeApplied + ) + const allDeletedFilePathsAccepted = session.state.deletedFiles?.every( + (filePath: DeletedFileInfo) => !filePath.rejected && filePath.changeApplied + ) + if (allFilePathsAccepted && allDeletedFilePathsAccepted) { + this.sendUpdateCodeMessage(tabId) + this.workOnNewTask( + tabId, + session.state.codeGenerationRemainingIterationCount, + session.state.codeGenerationTotalIterationCount + ) + await this.clearAcceptCodeMessageId(tabId) + } + } + } + + private async storeCodeResultMessageId(message: StoreMessageIdMessage) { + const tabId: string = message.tabID + const messageId = message.messageId + const session = await this.sessionStorage.getSession(tabId) + + session.updateCodeResultMessageId(messageId) } private async openDiff(message: OpenDiffMessage) { @@ -787,6 +828,11 @@ export class FeatureDevController { } } + private async openFile(filePath: NewFileInfo) { + const absolutePath = path.join(filePath.workspaceFolder.uri.fsPath, filePath.relativePath) + await openFile(absolutePath) + } + private async stopResponse(message: any) { telemetry.ui_click.emit({ elementId: 'amazonq_stopCodeGeneration' }) this.messenger.sendAnswer({ @@ -857,6 +903,7 @@ export class FeatureDevController { private async newTask(message: any) { // Old session for the tab is ending, delete it so we can create a new one for the message id const session = await this.sessionStorage.getSession(message.tabID) + await session.disableFileList() telemetry.amazonq_endChat.emit({ amazonqConversationId: session.conversationId, amazonqEndOfTheConversationLatency: performance.now() - session.telemetry.sessionStartTime, @@ -881,6 +928,7 @@ export class FeatureDevController { this.messenger.sendChatInputEnabled(message.tabID, false) const session = await this.sessionStorage.getSession(message.tabID) + await session.disableFileList() telemetry.amazonq_endChat.emit({ amazonqConversationId: session.conversationId, amazonqEndOfTheConversationLatency: performance.now() - session.telemetry.sessionStartTime, @@ -903,4 +951,23 @@ export class FeatureDevController { private retriesRemaining(session: Session | undefined) { return session?.retries ?? defaultRetryLimit } + + private async clearAcceptCodeMessageId(tabID: string) { + const session = await this.sessionStorage.getSession(tabID) + session.updateAcceptCodeMessageId(undefined) + } + + private sendAcceptCodeTelemetry(session: Session, amazonqNumberOfFilesAccepted: number) { + // accepted code telemetry is only to be sent once per iteration of code generation + if (amazonqNumberOfFilesAccepted > 0 && !session.acceptCodeTelemetrySent) { + session.updateAcceptCodeTelemetrySent(true) + telemetry.amazonq_isAcceptedCodeChanges.emit({ + credentialStartUrl: AuthUtil.instance.startUrl, + amazonqConversationId: session.conversationId, + amazonqNumberOfFilesAccepted, + enabled: true, + result: 'Succeeded', + }) + } + } } diff --git a/packages/core/src/amazonqFeatureDev/controllers/chat/messenger/messenger.ts b/packages/core/src/amazonqFeatureDev/controllers/chat/messenger/messenger.ts index cac7f6ca571..29aa741dc80 100644 --- a/packages/core/src/amazonqFeatureDev/controllers/chat/messenger/messenger.ts +++ b/packages/core/src/amazonqFeatureDev/controllers/chat/messenger/messenger.ts @@ -15,6 +15,7 @@ import { AuthNeededException, OpenNewTabMessage, FileComponent, + UpdateAnswerMessage, } from '../../../views/connector/connector' import { AppToWebViewMessageDispatcher } from '../../../views/connector/connector' import { ChatItemAction } from '@aws/mynah-ui' @@ -33,6 +34,7 @@ export class Messenger { tabID: string canBeVoted?: boolean snapToTop?: boolean + messageId?: string }) { this.dispatcher.sendChatMessage( new ChatMessage( @@ -43,6 +45,7 @@ export class Messenger { relatedSuggestions: undefined, canBeVoted: params.canBeVoted ?? false, snapToTop: params.snapToTop ?? false, + messageId: params.messageId, }, params.tabID ) @@ -132,9 +135,16 @@ export class Messenger { tabID: string, filePaths: NewFileInfo[], deletedFiles: DeletedFileInfo[], - messageId: string + messageId: string, + disableFileActions: boolean ) { - this.dispatcher.updateFileComponent(new FileComponent(tabID, filePaths, deletedFiles, messageId)) + this.dispatcher.updateFileComponent( + new FileComponent(tabID, filePaths, deletedFiles, messageId, disableFileActions) + ) + } + + public updateChatAnswer(message: UpdateAnswerMessage) { + this.dispatcher.updateChatAnswer(message) } public sendUpdatePlaceholder(tabID: string, newPlaceholder: string) { diff --git a/packages/core/src/amazonqFeatureDev/session/session.ts b/packages/core/src/amazonqFeatureDev/session/session.ts index 204e974eee0..97110869b77 100644 --- a/packages/core/src/amazonqFeatureDev/session/session.ts +++ b/packages/core/src/amazonqFeatureDev/session/session.ts @@ -8,10 +8,12 @@ import * as path from 'path' import { ConversationNotStartedState, PrepareCodeGenState } from './sessionState' import { type DeletedFileInfo, + FollowUpTypes, type Interaction, type NewFileInfo, type SessionState, type SessionStateConfig, + UpdateFilesPathsParams, } from '../types' import { ConversationIdNotFoundError } from '../errors' import { referenceLogText } from '../constants' @@ -26,6 +28,10 @@ import { ReferenceLogViewProvider } from '../../codewhisperer/service/referenceL import { AuthUtil } from '../../codewhisperer/util/authUtil' import { getLogger } from '../../shared' import { logWithConversationId } from '../userFacingText' +import { CodeReference } from '../../amazonq/webview/ui/connector' +import { UpdateAnswerMessage } from '../views/connector/connector' +import { MynahIcons } from '@aws/mynah-ui' +import { i18n } from '../../shared/i18n-helper' export class Session { private _state?: SessionState | Omit private task: string = '' @@ -35,6 +41,9 @@ export class Session { private preloaderFinished = false private _latestMessage: string = '' private _telemetry: TelemetryHelper + private _codeResultMessageId: string | undefined = undefined + private _acceptCodeMessageId: string | undefined = undefined + private _acceptCodeTelemetrySent = false // Used to keep track of whether or not the current session is currently authenticating/needs authenticating public isAuthenticating: boolean @@ -145,17 +154,62 @@ export class Session { return resp.interaction } - public async updateFilesPaths( - tabID: string, - filePaths: NewFileInfo[], - deletedFiles: DeletedFileInfo[], - messageId: string - ) { - this.messenger.updateFileComponent(tabID, filePaths, deletedFiles, messageId) + public async updateFilesPaths(params: UpdateFilesPathsParams) { + const { tabID, filePaths, deletedFiles, messageId, disableFileActions = false } = params + this.messenger.updateFileComponent(tabID, filePaths, deletedFiles, messageId, disableFileActions) + await this.updateChatAnswer(tabID, this.getInsertCodePillText([...filePaths, ...deletedFiles])) + } + + public async updateChatAnswer(tabID: string, insertCodePillText: string) { + if (this._acceptCodeMessageId) { + const answer = new UpdateAnswerMessage( + { + messageId: this._acceptCodeMessageId, + messageType: 'system-prompt', + followUps: [ + { + pillText: insertCodePillText, + type: FollowUpTypes.InsertCode, + icon: 'ok' as MynahIcons, + status: 'success', + }, + { + pillText: i18n('AWS.amazonq.featureDev.pillText.provideFeedback'), + type: FollowUpTypes.ProvideFeedbackAndRegenerateCode, + icon: 'refresh' as MynahIcons, + status: 'info', + }, + ], + }, + tabID + ) + this.messenger.updateChatAnswer(answer) + } } public async insertChanges() { - for (const filePath of this.state.filePaths?.filter((i) => !i.rejected) ?? []) { + const newFilePaths = + this.state.filePaths?.filter((filePath) => !filePath.rejected && !filePath.changeApplied) ?? [] + await this.insertNewFiles(newFilePaths) + + const deletedFiles = + this.state.deletedFiles?.filter((deletedFile) => !deletedFile.rejected && !deletedFile.changeApplied) ?? [] + await this.applyDeleteFiles(deletedFiles) + + await this.insertCodeReferenceLogs(this.state.references ?? []) + + if (this._codeResultMessageId) { + await this.updateFilesPaths({ + tabID: this.state.tabID, + filePaths: this.state.filePaths ?? [], + deletedFiles: this.state.deletedFiles ?? [], + messageId: this._codeResultMessageId, + }) + } + } + + public async insertNewFiles(newFilePaths: NewFileInfo[]) { + for (const filePath of newFilePaths) { const absolutePath = path.join(filePath.workspaceFolder.uri.fsPath, filePath.relativePath) const uri = filePath.virtualMemoryUri @@ -164,18 +218,61 @@ export class Session { await fs.mkdir(path.dirname(absolutePath)) await fs.writeFile(absolutePath, decodedContent) + filePath.changeApplied = true } + } - for (const filePath of this.state.deletedFiles?.filter((i) => !i.rejected) ?? []) { + public async applyDeleteFiles(deletedFiles: DeletedFileInfo[]) { + for (const filePath of deletedFiles) { const absolutePath = path.join(filePath.workspaceFolder.uri.fsPath, filePath.relativePath) await fs.delete(absolutePath) + filePath.changeApplied = true } + } - for (const ref of this.state.references ?? []) { + public async insertCodeReferenceLogs(codeReferences: CodeReference[]) { + for (const ref of codeReferences) { ReferenceLogViewProvider.instance.addReferenceLog(referenceLogText(ref)) } } + public async disableFileList() { + if (this._codeResultMessageId === undefined) { + return + } + + await this.updateFilesPaths({ + tabID: this.state.tabID, + filePaths: this.state.filePaths ?? [], + deletedFiles: this.state.deletedFiles ?? [], + messageId: this._codeResultMessageId, + disableFileActions: true, + }) + this._codeResultMessageId = undefined + } + + public updateCodeResultMessageId(messageId?: string) { + this._codeResultMessageId = messageId + } + + public updateAcceptCodeMessageId(messageId?: string) { + this._acceptCodeMessageId = messageId + } + + public updateAcceptCodeTelemetrySent(sent: boolean) { + this._acceptCodeTelemetrySent = sent + } + + public getInsertCodePillText(files: Array) { + if (files.every((file) => file.rejected || file.changeApplied)) { + return i18n('AWS.amazonq.featureDev.pillText.continue') + } + if (files.some((file) => file.rejected || file.changeApplied)) { + return i18n('AWS.amazonq.featureDev.pillText.acceptRemainingChanges') + } + return i18n('AWS.amazonq.featureDev.pillText.acceptAllChanges') + } + get state() { if (!this._state) { throw new Error("State should be initialized before it's read") @@ -220,4 +317,12 @@ export class Session { get telemetry() { return this._telemetry } + + get acceptCodeMessageId() { + return this._acceptCodeMessageId + } + + get acceptCodeTelemetrySent() { + return this._acceptCodeTelemetrySent + } } diff --git a/packages/core/src/amazonqFeatureDev/session/sessionState.ts b/packages/core/src/amazonqFeatureDev/session/sessionState.ts index 939234b5947..e04c28e74d3 100644 --- a/packages/core/src/amazonqFeatureDev/session/sessionState.ts +++ b/packages/core/src/amazonqFeatureDev/session/sessionState.ts @@ -102,6 +102,7 @@ export function registerNewFiles( workspaceFolderPrefixes === undefined ? 0 : prefix.length > 0 ? prefix.length + 1 : 0 ), rejected: false, + changeApplied: false, }) } @@ -127,6 +128,7 @@ function getDeletedFileInfos(deletedFiles: string[], workspaceFolders: CurrentWs workspaceFolder: folder, relativePath: deletedFilePath.substring(prefixLength), rejected: false, + changeApplied: false, } }) .filter(isPresent) @@ -426,6 +428,7 @@ export class MockCodeGenState implements SessionState { workspaceFolder: this.config.workspaceFolders[0], relativePath: 'src/this-file-should-be-deleted.ts', rejected: false, + changeApplied: false, }, ] action.messenger.sendCodeResult( @@ -447,7 +450,7 @@ export class MockCodeGenState implements SessionState { type: 'system-prompt', followUps: [ { - pillText: i18n('AWS.amazonq.featureDev.pillText.insertCode'), + pillText: i18n('AWS.amazonq.featureDev.pillText.acceptAllChanges'), type: FollowUpTypes.InsertCode, icon: 'ok' as MynahIcons, status: 'success', diff --git a/packages/core/src/amazonqFeatureDev/types.ts b/packages/core/src/amazonqFeatureDev/types.ts index cf046743425..499ddd22c5d 100644 --- a/packages/core/src/amazonqFeatureDev/types.ts +++ b/packages/core/src/amazonqFeatureDev/types.ts @@ -114,3 +114,11 @@ export interface SessionStorage { } export type LLMResponseType = 'EMPTY' | 'INVALID_STATE' | 'VALID' + +export interface UpdateFilesPathsParams { + tabID: string + filePaths: NewFileInfo[] + deletedFiles: DeletedFileInfo[] + messageId: string + disableFileActions?: boolean +} diff --git a/packages/core/src/amazonqFeatureDev/views/actions/uiMessageListener.ts b/packages/core/src/amazonqFeatureDev/views/actions/uiMessageListener.ts index 33ed5205c91..5d92fb7188c 100644 --- a/packages/core/src/amazonqFeatureDev/views/actions/uiMessageListener.ts +++ b/packages/core/src/amazonqFeatureDev/views/actions/uiMessageListener.ts @@ -64,6 +64,9 @@ export class UIMessageListener { case 'file-click': this.fileClicked(msg) break + case 'store-code-result-message-id': + this.storeCodeResultMessageId(msg) + break } } @@ -156,4 +159,11 @@ export class UIMessageListener { codeReference: msg.codeReference, }) } + + private storeCodeResultMessageId(msg: any) { + this.featureDevControllerEventsEmitters?.storeCodeResultMessageId.fire({ + messageId: msg.messageId, + tabID: msg.tabID, + }) + } } diff --git a/packages/core/src/amazonqFeatureDev/views/connector/connector.ts b/packages/core/src/amazonqFeatureDev/views/connector/connector.ts index 9d1e681bf9c..a45731cc11a 100644 --- a/packages/core/src/amazonqFeatureDev/views/connector/connector.ts +++ b/packages/core/src/amazonqFeatureDev/views/connector/connector.ts @@ -87,12 +87,20 @@ export class FileComponent extends UiMessage { readonly deletedFiles: DeletedFileInfo[] override type = 'updateFileComponent' readonly messageId: string + readonly disableFileActions: boolean - constructor(tabID: string, filePaths: NewFileInfo[], deletedFiles: DeletedFileInfo[], messageId: string) { + constructor( + tabID: string, + filePaths: NewFileInfo[], + deletedFiles: DeletedFileInfo[], + messageId: string, + disableFileActions: boolean + ) { super(tabID) this.filePaths = filePaths this.deletedFiles = deletedFiles this.messageId = messageId + this.disableFileActions = disableFileActions } } @@ -154,6 +162,7 @@ export interface ChatMessageProps { readonly relatedSuggestions: SourceLink[] | undefined readonly canBeVoted: boolean readonly snapToTop: boolean + readonly messageId?: string } export class ChatMessage extends UiMessage { @@ -164,6 +173,7 @@ export class ChatMessage extends UiMessage { readonly canBeVoted: boolean readonly requestID!: string readonly snapToTop: boolean + readonly messageId: string | undefined override type = 'chatMessage' constructor(props: ChatMessageProps, tabID: string) { @@ -174,6 +184,27 @@ export class ChatMessage extends UiMessage { this.relatedSuggestions = props.relatedSuggestions this.canBeVoted = props.canBeVoted this.snapToTop = props.snapToTop + this.messageId = props.messageId + } +} + +export interface UpdateAnswerMessageProps { + readonly messageId: string + readonly messageType: ChatItemType + readonly followUps: ChatItemAction[] | undefined +} + +export class UpdateAnswerMessage extends UiMessage { + readonly messageId: string + readonly messageType: ChatItemType + readonly followUps: ChatItemAction[] | undefined + override type = 'updateChatAnswer' + + constructor(props: UpdateAnswerMessageProps, tabID: string) { + super(tabID) + this.messageId = props.messageId + this.messageType = props.messageType + this.followUps = props.followUps } } @@ -216,7 +247,11 @@ export class AppToWebViewMessageDispatcher { this.appsToWebViewMessagePublisher.publish(message) } - public updateFileComponent(message: any) { + public updateFileComponent(message: FileComponent) { + this.appsToWebViewMessagePublisher.publish(message) + } + + public updateChatAnswer(message: UpdateAnswerMessage) { this.appsToWebViewMessagePublisher.publish(message) } } diff --git a/packages/core/src/test/amazonq/common/diff.test.ts b/packages/core/src/test/amazonq/common/diff.test.ts index 94c9840c23f..e76afcd3319 100644 --- a/packages/core/src/test/amazonq/common/diff.test.ts +++ b/packages/core/src/test/amazonq/common/diff.test.ts @@ -12,7 +12,14 @@ import assert from 'assert' import * as path from 'path' import * as vscode from 'vscode' import sinon from 'sinon' -import { createAmazonQUri, getFileDiffUris, getOriginalFileUri, openDeletedDiff, openDiff } from '../../../amazonq' +import { + createAmazonQUri, + getFileDiffUris, + getOriginalFileUri, + openDeletedDiff, + openDiff, + openFile, +} from '../../../amazonq' import { FileSystem } from '../../../shared/fs/fs' describe('diff', () => { @@ -60,16 +67,18 @@ describe('diff', () => { sandbox.stub(FileSystem.prototype, 'exists').resolves(true) await openDeletedDiff(filePath, name, tabId) - const expectedPath = vscode.Uri.file(filePath) - assert.ok(executeCommandSpy.calledWith('vscode.open', expectedPath, {}, `${name} (Deleted)`)) + const leftExpected = vscode.Uri.file(filePath) + const rightExpected = createAmazonQUri('empty', tabId) + assert.ok(executeCommandSpy.calledWith('vscode.diff', leftExpected, rightExpected, `${name} (Deleted)`)) }) it('file does not exists locally', async () => { sandbox.stub(FileSystem.prototype, 'exists').resolves(false) await openDeletedDiff(filePath, name, tabId) - const expectedPath = createAmazonQUri('empty', tabId) - assert.ok(executeCommandSpy.calledWith('vscode.open', expectedPath, {}, `${name} (Deleted)`)) + const leftExpected = createAmazonQUri('empty', tabId) + const rightExpected = createAmazonQUri('empty', tabId) + assert.ok(executeCommandSpy.calledWith('vscode.diff', leftExpected, rightExpected, `${name} (Deleted)`)) }) }) @@ -110,4 +119,21 @@ describe('diff', () => { assert.deepStrictEqual(right, rightExpected) }) }) + + describe('openFile', () => { + it('file exists locally', async () => { + sandbox.stub(FileSystem.prototype, 'exists').resolves(true) + await openFile(filePath) + + const expected = vscode.Uri.file(filePath) + assert.ok(executeCommandSpy.calledWith('vscode.diff', expected, expected)) + }) + + it('file does not exists locally', async () => { + sandbox.stub(FileSystem.prototype, 'exists').resolves(false) + await openFile(filePath) + + assert.ok(executeCommandSpy.notCalled) + }) + }) }) diff --git a/packages/core/src/test/amazonqFeatureDev/controllers/chat/controller.test.ts b/packages/core/src/test/amazonqFeatureDev/controllers/chat/controller.test.ts index e7e8ecdb6fc..2e200aaa3b3 100644 --- a/packages/core/src/test/amazonqFeatureDev/controllers/chat/controller.test.ts +++ b/packages/core/src/test/amazonqFeatureDev/controllers/chat/controller.test.ts @@ -9,7 +9,7 @@ import * as path from 'path' import sinon from 'sinon' import { waitUntil } from '../../../../shared/utilities/timeoutUtils' import { ControllerSetup, createController, createSession, generateVirtualMemoryUri } from '../../utils' -import { CurrentWsFolders, FollowUpTypes, NewFileInfo, DeletedFileInfo } from '../../../../amazonqFeatureDev/types' +import { CurrentWsFolders, DeletedFileInfo, FollowUpTypes, NewFileInfo } from '../../../../amazonqFeatureDev/types' import { Session } from '../../../../amazonqFeatureDev/session/session' import { Prompter } from '../../../../shared/ui/prompter' import { assertTelemetry, toFile } from '../../../testUtil' @@ -53,6 +53,7 @@ describe('Controller', () => { rejected: false, virtualMemoryUri: generateVirtualMemoryUri(uploadID, 'myfile1.js'), workspaceFolder: controllerSetup.workspaceFolder, + changeApplied: false, }, { zipFilePath: 'myfile2.js', @@ -61,6 +62,7 @@ describe('Controller', () => { rejected: true, virtualMemoryUri: generateVirtualMemoryUri(uploadID, 'myfile2.js'), workspaceFolder: controllerSetup.workspaceFolder, + changeApplied: false, }, ] @@ -70,12 +72,14 @@ describe('Controller', () => { relativePath: 'myfile3.js', rejected: false, workspaceFolder: controllerSetup.workspaceFolder, + changeApplied: false, }, { zipFilePath: 'myfile4.js', relativePath: 'myfile4.js', rejected: true, workspaceFolder: controllerSetup.workspaceFolder, + changeApplied: false, }, ] @@ -398,6 +402,7 @@ describe('Controller', () => { } describe('processErrorChatMessage', function () { + // TODO: fix disablePreviousFileList error const runs = [ { name: 'ContentLengthError', error: new ContentLengthError() }, { @@ -519,4 +524,28 @@ describe('Controller', () => { assertTelemetry('ui_click', { elementId: 'amazonq_stopCodeGeneration' }) }) }) + + describe('closeSession', async () => { + async function closeSessionClicked() { + const getSessionStub = sinon.stub(controllerSetup.sessionStorage, 'getSession').resolves(session) + + controllerSetup.emitters.followUpClicked.fire({ + tabID, + followUp: { + type: FollowUpTypes.CloseSession, + }, + }) + + // Wait until the controller has time to process the event + await waitUntil(() => { + return Promise.resolve(getSessionStub.callCount > 0) + }, {}) + } + + it('end chat telemetry is sent', async () => { + await closeSessionClicked() + + assertTelemetry('amazonq_endChat', { amazonqConversationId: conversationID, result: 'Succeeded' }) + }) + }) }) diff --git a/packages/core/src/test/amazonqFeatureDev/utils.ts b/packages/core/src/test/amazonqFeatureDev/utils.ts index 57e5fb72a9d..57e9eb7ed1c 100644 --- a/packages/core/src/test/amazonqFeatureDev/utils.ts +++ b/packages/core/src/test/amazonqFeatureDev/utils.ts @@ -39,6 +39,7 @@ export function createMockChatEmitters(): ChatControllerEventEmitters { processResponseBodyLinkClick: new vscode.EventEmitter(), insertCodeAtPositionClicked: new vscode.EventEmitter(), fileClicked: new vscode.EventEmitter(), + storeCodeResultMessageId: new vscode.EventEmitter(), } }