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(), } }