diff --git a/.changes/next-release/feature-6403bbaf-0521-4d39-abbf-f9e25fe5a837.json b/.changes/next-release/feature-6403bbaf-0521-4d39-abbf-f9e25fe5a837.json new file mode 100644 index 0000000000..429c3c21eb --- /dev/null +++ b/.changes/next-release/feature-6403bbaf-0521-4d39-abbf-f9e25fe5a837.json @@ -0,0 +1,4 @@ +{ + "type" : "feature", + "description" : "Amazon Q /dev: Add an action to accept individual files" +} \ No newline at end of file diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/FeatureDevApp.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/FeatureDevApp.kt index 2eb428563e..bc5bcfc6a7 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/FeatureDevApp.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/FeatureDevApp.kt @@ -42,7 +42,8 @@ class FeatureDevApp : AmazonQApp { "insert_code_at_cursor_position" to IncomingFeatureDevMessage.InsertCodeAtCursorPosition::class, "open-diff" to IncomingFeatureDevMessage.OpenDiff::class, "file-click" to IncomingFeatureDevMessage.FileClicked::class, - "stop-response" to IncomingFeatureDevMessage.StopResponse::class + "stop-response" to IncomingFeatureDevMessage.StopResponse::class, + "store-code-result-message-id" to IncomingFeatureDevMessage.StoreMessageIdMessage::class ) scope.launch { @@ -84,6 +85,7 @@ class FeatureDevApp : AmazonQApp { is IncomingFeatureDevMessage.OpenDiff -> inboundAppMessagesHandler.processOpenDiff(message) is IncomingFeatureDevMessage.FileClicked -> inboundAppMessagesHandler.processFileClicked(message) is IncomingFeatureDevMessage.StopResponse -> inboundAppMessagesHandler.processStopMessage(message) + is IncomingFeatureDevMessage.StoreMessageIdMessage -> inboundAppMessagesHandler.processStoreCodeResultMessageId(message) } } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/InboundAppMessagesHandler.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/InboundAppMessagesHandler.kt index 14ca39cd55..2c4d8cd0e1 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/InboundAppMessagesHandler.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/InboundAppMessagesHandler.kt @@ -6,6 +6,7 @@ package software.aws.toolkits.jetbrains.services.amazonqFeatureDev import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.messages.IncomingFeatureDevMessage interface InboundAppMessagesHandler { + suspend fun processPromptChatMessage(message: IncomingFeatureDevMessage.ChatPrompt) suspend fun processNewTabCreatedMessage(message: IncomingFeatureDevMessage.NewTabCreated) suspend fun processTabRemovedMessage(message: IncomingFeatureDevMessage.TabRemoved) @@ -18,4 +19,5 @@ interface InboundAppMessagesHandler { suspend fun processOpenDiff(message: IncomingFeatureDevMessage.OpenDiff) suspend fun processFileClicked(message: IncomingFeatureDevMessage.FileClicked) suspend fun processStopMessage(message: IncomingFeatureDevMessage.StopResponse) + suspend fun processStoreCodeResultMessageId(message: IncomingFeatureDevMessage.StoreMessageIdMessage) } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevController.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevController.kt index 84d2cd084c..2afe8fbef4 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevController.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevController.kt @@ -5,8 +5,10 @@ package software.aws.toolkits.jetbrains.services.amazonqFeatureDev.controller import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.intellij.diff.DiffContentFactory -import com.intellij.diff.DiffManager +import com.intellij.diff.chains.SimpleDiffRequestChain import com.intellij.diff.contents.EmptyContent +import com.intellij.diff.editor.ChainDiffVirtualFile +import com.intellij.diff.editor.DiffEditorTabFilesManager import com.intellij.diff.requests.SimpleDiffRequest import com.intellij.ide.BrowserUtil import com.intellij.openapi.application.runInEdt @@ -17,6 +19,7 @@ import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VfsUtil import com.intellij.openapi.wm.ToolWindowManager +import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import software.amazon.awssdk.services.toolkittelemetry.model.Sentiment import software.aws.toolkits.core.utils.debug @@ -65,7 +68,10 @@ import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.session.Prepar import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.session.Session import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.session.SessionStatePhase import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.storage.ChatSessionStorage +import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.InsertAction +import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.getFollowUpOptions import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.selectFolder +import software.aws.toolkits.jetbrains.services.codewhisperer.util.content import software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry.FeedbackComment import software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry.getStartUrl import software.aws.toolkits.jetbrains.services.telemetry.TelemetryService @@ -86,6 +92,8 @@ class FeatureDevController( val messenger = context.messagesFromAppToUi val toolWindow = ToolWindowManager.getInstance(context.project).getToolWindow(AmazonQToolWindowFactory.WINDOW_ID) + private val diffVirtualFiles = mutableMapOf() + override suspend fun processPromptChatMessage(message: IncomingFeatureDevMessage.ChatPrompt) { handleChat( tabId = message.tabId, @@ -93,6 +101,10 @@ class FeatureDevController( ) } + override suspend fun processStoreCodeResultMessageId(message: IncomingFeatureDevMessage.StoreMessageIdMessage) { + storeCodeResultMessageId(message) + } + override suspend fun processStopMessage(message: IncomingFeatureDevMessage.StopResponse) { handleStopMessage(message) } @@ -126,6 +138,7 @@ class FeatureDevController( override suspend fun processChatItemVotedMessage(message: IncomingFeatureDevMessage.ChatItemVotedMessage) { logger.debug { "$FEATURE_NAME: Processing ChatItemVotedMessage: $message" } + this.disablePreviousFileList(message.tabId) val session = chatSessionStorage.getSession(message.tabId, context.project) when (message.vote) { @@ -192,6 +205,18 @@ class FeatureDevController( } } + private fun putDiff(filePath: String, request: SimpleDiffRequest) { + // Close any existing diff and open a new diff, as the diff virtual file does not appear to allow replacing content directly: + val existingDiff = diffVirtualFiles[filePath] + if (existingDiff != null) { + FileEditorManager.getInstance(context.project).closeFile(existingDiff) + } + + val newDiff = ChainDiffVirtualFile(SimpleDiffRequestChain(request), filePath) + DiffEditorTabFilesManager.getInstance(context.project).showDiffFile(newDiff, true) + diffVirtualFiles[filePath] = newDiff + } + override suspend fun processOpenDiff(message: IncomingFeatureDevMessage.OpenDiff) { val session = getSessionInfo(message.tabId) @@ -223,9 +248,7 @@ class FeatureDevController( DiffContentFactory.getInstance().create(newFileContent) } - val request = SimpleDiffRequest(message.filePath, leftDiffContent, rightDiffContent, null, null) - - DiffManager.getInstance().showDiff(project, request) + putDiff(message.filePath, SimpleDiffRequest(message.filePath, leftDiffContent, rightDiffContent, null, null)) } } else -> { @@ -244,21 +267,81 @@ class FeatureDevController( val fileToUpdate = message.filePath val session = getSessionInfo(message.tabId) val messageId = message.messageId + val action = message.actionName var filePaths: List = emptyList() var deletedFiles: List = emptyList() + var references: List = emptyList() when (val state = session.sessionState) { is PrepareCodeGenerationState -> { filePaths = state.filePaths deletedFiles = state.deletedFiles + references = state.references } } - // Mark the file as rejected or not depending on the previous state - filePaths.find { it.zipFilePath == fileToUpdate }?.let { it.rejected = !it.rejected } - deletedFiles.find { it.zipFilePath == fileToUpdate }?.let { it.rejected = !it.rejected } + fun insertAction(): InsertAction = + if (filePaths.all { it.changeApplied } && deletedFiles.all { it.changeApplied }) { + InsertAction.AUTO_CONTINUE + } else if (filePaths.all { it.changeApplied || it.rejected } && deletedFiles.all { it.changeApplied || it.rejected }) { + InsertAction.CONTINUE + } else if (filePaths.any { it.changeApplied || it.rejected } || deletedFiles.any { it.changeApplied || it.rejected }) { + InsertAction.REMAINING + } else { + InsertAction.ALL + } + + val prevInsertAction = insertAction() + + if (action == "accept-change") { + session.insertChanges( + filePaths = filePaths.filter { it.zipFilePath == fileToUpdate }, + deletedFiles = deletedFiles.filter { it.zipFilePath == fileToUpdate }, + references = references, // Add all references (not attributed per-file) + messenger + ) + + AmazonqTelemetry.isAcceptedCodeChanges( + amazonqNumberOfFilesAccepted = 1.0, + amazonqConversationId = session.conversationId, + enabled = true, + credentialStartUrl = getStartUrl(project = context.project) + ) + } else { + // Mark the file as rejected or not depending on the previous state + filePaths.find { it.zipFilePath == fileToUpdate }?.let { it.rejected = !it.rejected } + deletedFiles.find { it.zipFilePath == fileToUpdate }?.let { it.rejected = !it.rejected } + } + // Update the state of the tree view: messenger.updateFileComponent(message.tabId, filePaths, deletedFiles, messageId) + + // Then, if the accepted file is not a deletion, open a diff to show the changes are applied: + if (action == "accept-change" && deletedFiles.none { it.zipFilePath == fileToUpdate }) { + var pollAttempt = 0 + val pollDelayMs = 10L + while (pollAttempt < 5) { + val file = VfsUtil.findRelativeFile(message.filePath, session.context.selectedSourceFolder) + // Wait for the file to be created and/or updated to the new content: + if (file != null && file.content() == filePaths.find { it.zipFilePath == fileToUpdate }?.fileContent) { + // Open a diff, showing the changes have been applied and the file now has identical left/right state: + this.processOpenDiff(IncomingFeatureDevMessage.OpenDiff(message.tabId, fileToUpdate, false)) + break + } else { + pollAttempt++ + delay(pollDelayMs) + } + } + } + + val nextInsertAction = insertAction() + if (nextInsertAction == InsertAction.AUTO_CONTINUE) { + // Insert remaining changes (noop, as there are none), and advance to the next prompt: + insertCode(message.tabId) + } else if (nextInsertAction != prevInsertAction) { + // Update the action displayed to the customer based on the current state: + messenger.sendSystemPrompt(message.tabId, getFollowUpOptions(session.sessionState.phase, nextInsertAction)) + } } private suspend fun newTabOpened(tabId: String) { @@ -308,7 +391,8 @@ class FeatureDevController( session.sessionState.token?.cancel() } } - private suspend fun insertCode(tabId: String) { + + suspend fun insertCode(tabId: String) { var session: Session? = null try { session = getSessionInfo(tabId) @@ -325,17 +409,22 @@ class FeatureDevController( } } + val rejectedFilesCount = filePaths.count { it.rejected } + deletedFiles.count { it.rejected } + val acceptedFilesCount = filePaths.count { it.changeApplied } + filePaths.count { it.changeApplied } + val remainingFilesCount = filePaths.count() + deletedFiles.count() - acceptedFilesCount - rejectedFilesCount + AmazonqTelemetry.isAcceptedCodeChanges( - amazonqNumberOfFilesAccepted = (filePaths.filterNot { it.rejected }.size + deletedFiles.filterNot { it.rejected }.size) * 1.0, + amazonqNumberOfFilesAccepted = remainingFilesCount.toDouble(), amazonqConversationId = session.conversationId, enabled = true, credentialStartUrl = getStartUrl(project = context.project) ) session.insertChanges( - filePaths = filePaths.filterNot { it.rejected }, - deletedFiles = deletedFiles.filterNot { it.rejected }, - references = references + filePaths = filePaths.filterNot { it.rejected || it.changeApplied }, + deletedFiles = deletedFiles.filterNot { it.rejected || it.changeApplied }, + references = references, + messenger ) messenger.sendAnswer( @@ -377,8 +466,11 @@ class FeatureDevController( } private suspend fun newTask(tabId: String, isException: Boolean? = false) { + this.disablePreviousFileList(tabId) + val session = getSessionInfo(tabId) val sessionLatency = System.currentTimeMillis() - session.sessionStartTime + AmazonqTelemetry.endChat( amazonqConversationId = session.conversationId, amazonqEndOfTheConversationLatency = sessionLatency.toDouble(), @@ -399,6 +491,7 @@ class FeatureDevController( } private suspend fun closeSession(tabId: String) { + this.disablePreviousFileList(tabId) messenger.sendAnswer( tabId = tabId, messageType = FeatureDevMessageType.Answer, @@ -503,7 +596,7 @@ class FeatureDevController( tabId = tabId, followUp = listOf( FollowUp( - pillText = message("amazonqFeatureDev.follow_up.insert_code"), + pillText = message("amazonqFeatureDev.follow_up.insert_all_code"), type = FollowUpTypes.INSERT_CODE, icon = FollowUpIcons.Ok, status = FollowUpStatusType.Success, @@ -546,11 +639,28 @@ class FeatureDevController( } } + private suspend fun disablePreviousFileList(tabId: String) { + val session = getSessionInfo(tabId) + when (val sessionState = session.sessionState) { + is PrepareCodeGenerationState -> { + session.disableFileList(sessionState.filePaths, sessionState.deletedFiles, messenger) + } + } + } + + private fun storeCodeResultMessageId(message: IncomingFeatureDevMessage.StoreMessageIdMessage) { + val tabId = message.tabId + val session = getSessionInfo(tabId) + session.storeCodeResultMessageId(message) + } + private suspend fun handleChat( tabId: String, message: String, ) { var session: Session? = null + + this.disablePreviousFileList(tabId) try { logger.debug { "$FEATURE_NAME: Processing message: $message" } session = getSessionInfo(tabId) diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevControllerExtensions.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevControllerExtensions.kt index 949e1ca42e..19331d7f61 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevControllerExtensions.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevControllerExtensions.kt @@ -23,6 +23,7 @@ import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.session.Prepar import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.session.Session import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.session.SessionState import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.CancellationTokenSource +import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.InsertAction import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.getFollowUpOptions import software.aws.toolkits.jetbrains.utils.notifyInfo import software.aws.toolkits.resources.message @@ -88,7 +89,7 @@ suspend fun FeatureDevController.onCodeGeneration( } // Atm this is the only possible path as codegen is mocked to return empty. - if (filePaths.size or deletedFiles.size == 0) { + if (filePaths.isEmpty() && deletedFiles.isEmpty()) { messenger.sendAnswer( tabId = tabId, messageType = FeatureDevMessageType.Answer, @@ -132,7 +133,7 @@ suspend fun FeatureDevController.onCodeGeneration( ) } - messenger.sendSystemPrompt(tabId = tabId, followUp = getFollowUpOptions(session.sessionState.phase)) + messenger.sendSystemPrompt(tabId = tabId, followUp = getFollowUpOptions(session.sessionState.phase, InsertAction.ALL)) messenger.sendUpdatePlaceholder(tabId = tabId, newPlaceholder = message("amazonqFeatureDev.placeholder.after_code_generation")) } finally { diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/messages/FeatureDevMessage.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/messages/FeatureDevMessage.kt index 1ad226f96b..be8be6d18a 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/messages/FeatureDevMessage.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/messages/FeatureDevMessage.kt @@ -24,6 +24,12 @@ sealed interface IncomingFeatureDevMessage : FeatureDevBaseMessage { @JsonProperty("tabID") val tabId: String, ) : IncomingFeatureDevMessage + data class StoreMessageIdMessage( + @JsonProperty("tabID") val tabId: String, + val command: String, + val messageId: String?, + ) : IncomingFeatureDevMessage + data class NewTabCreated( val command: String, @JsonProperty("tabID") val tabId: String, @@ -149,6 +155,7 @@ data class FileComponent( val filePaths: List, val deletedFiles: List, val messageId: String, + val disableFileActions: Boolean = false, ) : UiMessage( tabId = tabId, type = "updateFileComponent" @@ -198,6 +205,7 @@ data class CodeResultMessage( val filePaths: List, val deletedFiles: List, val references: List, + val messageId: String?, ) : UiMessage( tabId = tabId, type = "codeResultMessage" diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/messages/FeatureDevMessagePublisherExtensions.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/messages/FeatureDevMessagePublisherExtensions.kt index 1aa249b750..5e757bdef6 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/messages/FeatureDevMessagePublisherExtensions.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/messages/FeatureDevMessagePublisherExtensions.kt @@ -16,6 +16,7 @@ import java.util.UUID suspend fun MessagePublisher.sendAnswer( tabId: String, message: String? = null, + messageId: String? = null, messageType: FeatureDevMessageType, followUp: List? = null, canBeVoted: Boolean? = false, @@ -25,12 +26,12 @@ suspend fun MessagePublisher.sendAnswer( FeatureDevMessage( tabId = tabId, triggerId = UUID.randomUUID().toString(), - messageId = UUID.randomUUID().toString(), + messageId = messageId ?: UUID.randomUUID().toString(), messageType = messageType, message = message, followUps = followUp, canBeVoted = canBeVoted ?: false, - snapToTop = snapToTop ?: false + snapToTop = snapToTop ?: false, ) this.publish(chatMessage) } @@ -44,32 +45,45 @@ suspend fun MessagePublisher.sendAnswerPart( tabId = tabId, message = message, messageType = FeatureDevMessageType.AnswerPart, - canBeVoted = canBeVoted + canBeVoted = canBeVoted, ) } +/** Send or replace system prompt. Only one system prompt can be rendered for a given tab. */ suspend fun MessagePublisher.sendSystemPrompt( tabId: String, followUp: List, ) { this.sendAnswer( tabId = tabId, + messageId = "$tabId-system-prompt", messageType = FeatureDevMessageType.SystemPrompt, - followUp = followUp + followUp = followUp, ) } -suspend fun MessagePublisher.updateFileComponent(tabId: String, filePaths: List, deletedFiles: List, messageId: String) { +suspend fun MessagePublisher.updateFileComponent( + tabId: String, + filePaths: List, + deletedFiles: List, + messageId: String, + disableFileActions: Boolean = false, +) { val fileComponentMessage = FileComponent( tabId = tabId, filePaths = filePaths, deletedFiles = deletedFiles, messageId = messageId, + disableFileActions = disableFileActions, ) this.publish(fileComponentMessage) } -suspend fun MessagePublisher.sendAsyncEventProgress(tabId: String, inProgress: Boolean, message: String? = null) { +suspend fun MessagePublisher.sendAsyncEventProgress( + tabId: String, + inProgress: Boolean, + message: String? = null, +) { val asyncEventProgressMessage = AsyncEventProgressMessage( tabId = tabId, message = message, @@ -81,7 +95,7 @@ suspend fun MessagePublisher.sendAsyncEventProgress(tabId: String, inProgress: B suspend fun MessagePublisher.sendUpdatePlaceholder(tabId: String, newPlaceholder: String) { val updatePlaceholderMessage = UpdatePlaceholderMessage( tabId = tabId, - newPlaceholder = newPlaceholder + newPlaceholder = newPlaceholder, ) this.publish(updatePlaceholderMessage) } @@ -103,6 +117,7 @@ suspend fun MessagePublisher.sendAuthenticationInProgressMessage(tabId: String) message = message("amazonqFeatureDev.follow_instructions_for_authentication"), ) } + suspend fun MessagePublisher.sendChatInputEnabledMessage(tabId: String, enabled: Boolean) { val chatInputEnabledMessage = ChatInputEnabledMessage( tabId, @@ -128,8 +143,8 @@ suspend fun MessagePublisher.sendError(tabId: String, errMessage: String?, retri FollowUp( pillText = message("amazonqFeatureDev.follow_up.send_feedback"), type = FollowUpTypes.SEND_FEEDBACK, - status = FollowUpStatusType.Info - ) + status = FollowUpStatusType.Info, + ), ), ) return @@ -144,12 +159,13 @@ suspend fun MessagePublisher.sendError(tabId: String, errMessage: String?, retri this.sendAnswer( tabId = tabId, messageType = FeatureDevMessageType.SystemPrompt, - followUp = listOf( + followUp = + listOf( FollowUp( pillText = message("amazonqFeatureDev.follow_up.retry"), type = FollowUpTypes.RETRY, - status = FollowUpStatusType.Warning - ) + status = FollowUpStatusType.Warning, + ), ), ) } @@ -158,7 +174,7 @@ suspend fun MessagePublisher.sendMonthlyLimitError(tabId: String) { this.sendAnswer( tabId = tabId, messageType = FeatureDevMessageType.Answer, - message = message("amazonqFeatureDev.exception.monthly_limit_error") + message = message("amazonqFeatureDev.exception.monthly_limit_error"), ) this.sendUpdatePlaceholder(tabId = tabId, newPlaceholder = message("amazonqFeatureDev.placeholder.after_monthly_limit")) } @@ -178,6 +194,7 @@ suspend fun MessagePublisher.sendCodeResult( deletedFiles: List, references: List, ) { + val messageId = UUID.randomUUID() val refs = references.map { ref -> CodeReference( licenseName = ref.licenseName, @@ -187,7 +204,7 @@ suspend fun MessagePublisher.sendCodeResult( ref.recommendationContentSpan?.start ?: 0, ref.recommendationContentSpan?.end ?: 0, ), - information = "Reference code under **${ref.licenseName}** license from repository [${ref.repository}](${ref.url})" + information = "Reference code under **${ref.licenseName}** license from repository [${ref.repository}](${ref.url})", ) } @@ -197,7 +214,8 @@ suspend fun MessagePublisher.sendCodeResult( conversationId = uploadId, filePaths = filePaths, deletedFiles = deletedFiles, - references = refs - ) + references = refs, + messageId = messageId.toString(), + ), ) } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/CodeGenerationState.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/CodeGenerationState.kt index 4cccba2252..802db7dcf3 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/CodeGenerationState.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/CodeGenerationState.kt @@ -221,6 +221,7 @@ fun registerNewFiles(newFileContents: Map): List zipFilePath = it.key, fileContent = it.value, rejected = false, + changeApplied = false ) } @@ -229,5 +230,6 @@ fun registerDeletedFiles(deletedFiles: List): List = DeletedFileInfo( zipFilePath = it, rejected = false, + changeApplied = false ) } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/Session.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/Session.kt index f02fc86609..2a37e020ee 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/Session.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/Session.kt @@ -13,7 +13,9 @@ import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.ConversationId import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.FEATURE_NAME import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.MAX_PROJECT_SIZE_BYTES import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.clients.FeatureDevClient +import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.messages.IncomingFeatureDevMessage import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.messages.sendAsyncEventProgress +import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.messages.updateFileComponent import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.CancellationTokenSource import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.FeatureDevService import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.resolveAndCreateOrUpdateFile @@ -31,6 +33,7 @@ class Session(val tabID: String, val project: Project) { private var task: String = "" private val proxyClient: FeatureDevClient private val featureDevService: FeatureDevService + private var _codeResultMessageId: String? = null // retry session state vars private var codegenRetries: Int @@ -82,24 +85,65 @@ class Session(val tabID: String, val project: Project) { currentIteration = 1, // first code gen iteration uploadId = "", // There is no code gen uploadId so far messenger = messenger, - token = CancellationTokenSource() + token = CancellationTokenSource(), ) } + fun storeCodeResultMessageId(message: IncomingFeatureDevMessage.StoreMessageIdMessage) { + val messageId = message.messageId + this.updateCodeResultMessageId(messageId) + } + + private fun updateCodeResultMessageId(messageId: String?) { + this._codeResultMessageId = messageId + } + /** * Triggered by the Insert code follow-up button to apply code changes. */ - fun insertChanges(filePaths: List, deletedFiles: List, references: List) { + suspend fun insertChanges( + filePaths: List, + deletedFiles: List, + references: List, + messenger: MessagePublisher, + ) { val selectedSourceFolder = context.selectedSourceFolder.toNioPath() + val newFilePaths = filePaths.filter { !it.rejected && !it.changeApplied } + val newDeletedFiles = deletedFiles.filter { !it.rejected && !it.changeApplied } + newFilePaths.forEach { + resolveAndCreateOrUpdateFile(selectedSourceFolder, it.zipFilePath, it.fileContent) + it.changeApplied = true + } - filePaths.forEach { resolveAndCreateOrUpdateFile(selectedSourceFolder, it.zipFilePath, it.fileContent) } - - deletedFiles.forEach { resolveAndDeleteFile(selectedSourceFolder, it.zipFilePath) } + newDeletedFiles.forEach { + resolveAndDeleteFile(selectedSourceFolder, it.zipFilePath) + it.changeApplied = true + } ReferenceLogController.addReferenceLog(references, project) // Taken from https://intellij-support.jetbrains.com/hc/en-us/community/posts/206118439-Refresh-after-external-changes-to-project-structure-and-sources VfsUtil.markDirtyAndRefresh(true, true, true, context.selectedSourceFolder) + val codeResultMessageId = this._codeResultMessageId + if (codeResultMessageId != null) { + messenger.updateFileComponent(this.tabID, filePaths, deletedFiles, codeResultMessageId) + } + } + + suspend fun disableFileList( + filePaths: List, + deletedFiles: List, + messenger: MessagePublisher, + ) { + if (this._codeResultMessageId.isNullOrEmpty()) { + return + } + + val codeResultMessageId = this._codeResultMessageId + if (codeResultMessageId != null) { + messenger.updateFileComponent(this.tabID, filePaths, deletedFiles, codeResultMessageId, disableFileActions = true) + } + this._codeResultMessageId = null } suspend fun send(msg: String): Interaction { @@ -113,11 +157,12 @@ class Session(val tabID: String, val project: Project) { } private suspend fun nextInteraction(msg: String): Interaction { - var action = SessionStateAction( - task = task, - msg = msg, - token = sessionState.token - ) + var action = + SessionStateAction( + task = task, + msg = msg, + token = sessionState.token, + ) val resp = sessionState.interact(action) if (resp.nextState != null) { // Approach may have been changed after the interaction diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/SessionStateTypes.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/SessionStateTypes.kt index 33ee8cb7e4..e0af47770b 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/SessionStateTypes.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/SessionStateTypes.kt @@ -42,11 +42,13 @@ data class NewFileZipInfo( val zipFilePath: String, val fileContent: String, var rejected: Boolean, + var changeApplied: Boolean, ) data class DeletedFileInfo( val zipFilePath: String, // The string is the path of the file to be deleted var rejected: Boolean, + var changeApplied: Boolean, ) data class CodeGenerationResult( diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/util/FeatureDevControllerUtil.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/util/FeatureDevControllerUtil.kt index d231106b92..b336d0e085 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/util/FeatureDevControllerUtil.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/util/FeatureDevControllerUtil.kt @@ -10,12 +10,24 @@ import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.messages.Follo import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.session.SessionStatePhase import software.aws.toolkits.resources.message -fun getFollowUpOptions(phase: SessionStatePhase?): List { +enum class InsertAction { + ALL, + REMAINING, + CONTINUE, + AUTO_CONTINUE, +} + +fun getFollowUpOptions(phase: SessionStatePhase?, type: InsertAction): List { when (phase) { SessionStatePhase.CODEGEN -> { return listOf( FollowUp( - pillText = message("amazonqFeatureDev.follow_up.insert_code"), + pillText = when (type) { + InsertAction.ALL -> message("amazonqFeatureDev.follow_up.insert_all_code") + InsertAction.REMAINING -> message("amazonqFeatureDev.follow_up.insert_remaining_code") + InsertAction.CONTINUE -> message("amazonqFeatureDev.follow_up.continue") + InsertAction.AUTO_CONTINUE -> message("amazonqFeatureDev.follow_up.continue") + }, type = FollowUpTypes.INSERT_CODE, icon = FollowUpIcons.Ok, status = FollowUpStatusType.Success diff --git a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevControllerTest.kt b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevControllerTest.kt index 8992baaa5f..7b328b4e49 100644 --- a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevControllerTest.kt +++ b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevControllerTest.kt @@ -59,6 +59,7 @@ import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.session.Sessio import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.storage.ChatSessionStorage import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.CancellationTokenSource import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.FeatureDevService +import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.InsertAction import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.getFollowUpOptions import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.selectFolder import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.uploadArtifactToS3 @@ -81,13 +82,13 @@ class FeatureDevControllerTest : FeatureDevTestBase() { private val newFileContents = listOf( - NewFileZipInfo("test.ts", "This is a comment", false), - NewFileZipInfo("test2.ts", "This is a rejected file", true), + NewFileZipInfo("test.ts", "This is a comment", false, false), + NewFileZipInfo("test2.ts", "This is a rejected file", true, false), ) private val deletedFiles = listOf( - DeletedFileInfo("delete.ts", false), - DeletedFileInfo("delete2.ts", true), + DeletedFileInfo("delete.ts", false, false), + DeletedFileInfo("delete2.ts", true, false), ) @Before @@ -119,7 +120,7 @@ class FeatureDevControllerTest : FeatureDevTestBase() { mockkStatic("software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.UploadArtifactKt") every { uploadArtifactToS3(any(), any(), any(), any(), any()) } just runs - controller = FeatureDevController(appContext, chatSessionStorage, authController) + controller = spy(FeatureDevController(appContext, chatSessionStorage, authController)) } @After @@ -166,7 +167,7 @@ class FeatureDevControllerTest : FeatureDevTestBase() { mockitoVerify(chatSessionStorage, times(1)).deleteSession(testTabId) coVerifyOrder { - messenger.sendAnswer(testTabId, message("amazonqFeatureDev.chat_message.ask_for_new_task"), FeatureDevMessageType.Answer) + messenger.sendAnswer(testTabId, message("amazonqFeatureDev.chat_message.ask_for_new_task"), messageType = FeatureDevMessageType.Answer) messenger.sendUpdatePlaceholder(testTabId, message("amazonqFeatureDev.placeholder.new_plan")) } @@ -238,7 +239,7 @@ class FeatureDevControllerTest : FeatureDevTestBase() { ), ) - doNothing().`when`(spySession).insertChanges(any(), any(), any()) + doReturn(Unit).`when`(spySession).insertChanges(any(), any(), any(), any()) spySession.preloader(userMessage, messenger) controller.processFollowupClickedMessage(message) @@ -246,7 +247,7 @@ class FeatureDevControllerTest : FeatureDevTestBase() { mockitoVerify( spySession, times(1), - ).insertChanges(listOf(newFileContents[0]), listOf(deletedFiles[0]), testReferences) // insert changes for only non rejected files + ).insertChanges(listOf(newFileContents[0]), listOf(deletedFiles[0]), testReferences, messenger) // insert changes for only non rejected files coVerifyOrder { AmazonqTelemetry.isAcceptedCodeChanges( amazonqNumberOfFilesAccepted = 2.0, // it should be 2 files per test setup @@ -275,7 +276,7 @@ class FeatureDevControllerTest : FeatureDevTestBase() { fun `test handleChat onCodeGeneration succeeds to create files`() = runTest { val mockInteraction = mock() - var featureDevService = mockk() + val featureDevService = mockk() val repoContext = mock() val sessionStateConfig = SessionStateConfig(testConversationId, repoContext, featureDevService) mockkObject(AmazonqTelemetry) @@ -301,10 +302,10 @@ class FeatureDevControllerTest : FeatureDevTestBase() { coVerifyOrder { messenger.sendAsyncEventProgress(testTabId, true, message("amazonqFeatureDev.chat_message.start_code_generation_retry")) - messenger.sendAnswer(testTabId, message("amazonqFeatureDev.chat_message.requesting_changes"), FeatureDevMessageType.AnswerStream) + messenger.sendAnswer(testTabId, message("amazonqFeatureDev.chat_message.requesting_changes"), messageType = FeatureDevMessageType.AnswerStream) messenger.sendUpdatePlaceholder(testTabId, message("amazonqFeatureDev.placeholder.generating_code")) messenger.sendCodeResult(testTabId, testUploadId, newFileContents, deletedFiles, testReferences) - messenger.sendSystemPrompt(testTabId, getFollowUpOptions(SessionStatePhase.CODEGEN)) + messenger.sendSystemPrompt(testTabId, getFollowUpOptions(SessionStatePhase.CODEGEN, InsertAction.ALL)) messenger.sendUpdatePlaceholder(testTabId, message("amazonqFeatureDev.placeholder.after_code_generation")) messenger.sendAsyncEventProgress(testTabId, false) messenger.sendChatInputEnabledMessage(testTabId, false) @@ -323,7 +324,7 @@ class FeatureDevControllerTest : FeatureDevTestBase() { coVerifyOrder { messenger.sendAsyncEventProgress(testTabId, true, message("amazonqFeatureDev.chat_message.start_code_generation")) - messenger.sendAnswer(testTabId, message("amazonqFeatureDev.chat_message.requesting_changes"), FeatureDevMessageType.AnswerStream) + messenger.sendAnswer(testTabId, message("amazonqFeatureDev.chat_message.requesting_changes"), messageType = FeatureDevMessageType.AnswerStream) messenger.sendUpdatePlaceholder(testTabId, message("amazonqFeatureDev.placeholder.generating_code")) messenger.sendAsyncEventProgress(testTabId, false) messenger.sendChatInputEnabledMessage(testTabId, false) @@ -360,7 +361,7 @@ class FeatureDevControllerTest : FeatureDevTestBase() { controller.onCodeGeneration(mockSession, userMessage, testTabId) coVerifyOrder { - messenger.sendAnswer(testTabId, message("amazonqFeatureDev.code_generation.no_file_changes"), FeatureDevMessageType.Answer) + messenger.sendAnswer(testTabId, message("amazonqFeatureDev.code_generation.no_file_changes"), messageType = FeatureDevMessageType.Answer) messenger.sendSystemPrompt( testTabId, listOf(FollowUp(FollowUpTypes.RETRY, message("amazonqFeatureDev.follow_up.retry"), status = FollowUpStatusType.Warning)), @@ -399,16 +400,16 @@ class FeatureDevControllerTest : FeatureDevTestBase() { controller.onCodeGeneration(mockSession, userMessage, testTabId) coVerifyOrder { - messenger.sendAnswer(testTabId, message("amazonqFeatureDev.code_generation.no_file_changes"), FeatureDevMessageType.Answer) + messenger.sendAnswer(testTabId, message("amazonqFeatureDev.code_generation.no_file_changes"), messageType = FeatureDevMessageType.Answer) messenger.sendSystemPrompt(testTabId, emptyList()) messenger.sendChatInputEnabledMessage(testTabId, false) } } @Test - fun `test processFileClicked changes the state of the clicked file`() = + fun `test processFileClicked handles file rejection`() = runTest { - val message = IncomingFeatureDevMessage.FileClicked(testTabId, newFileContents[0].zipFilePath, "", "") + val message = IncomingFeatureDevMessage.FileClicked(testTabId, newFileContents[0].zipFilePath, "", "reject-change") whenever(featureDevClient.createTaskAssistConversation()).thenReturn(exampleCreateTaskAssistConversationResponse) whenever(chatSessionStorage.getSession(any(), any())).thenReturn(spySession) @@ -429,11 +430,128 @@ class FeatureDevControllerTest : FeatureDevTestBase() { controller.processFileClicked(message) - val newFileContentsCopy = newFileContents.toList() - newFileContentsCopy[0].rejected = !newFileContentsCopy[0].rejected + val newFileContentsCopy = newFileContents.toMutableList() + newFileContentsCopy[0] = newFileContentsCopy[0].copy() + newFileContentsCopy[0].rejected = true + newFileContentsCopy[0].changeApplied = false coVerify { messenger.updateFileComponent(testTabId, newFileContentsCopy, deletedFiles, "") } } + @Test + fun `test processFileClicked handles file acceptance`() = + runTest { + val featureDevService = mockk() + val repoContext = mock() + val sessionStateConfig = SessionStateConfig(testConversationId, repoContext, featureDevService) + + whenever(featureDevClient.createTaskAssistConversation()).thenReturn(exampleCreateTaskAssistConversationResponse) + whenever(chatSessionStorage.getSession(any(), any())).thenReturn(spySession) + whenever(spySession.sessionState).thenReturn( + PrepareCodeGenerationState( + testTabId, + CancellationTokenSource(), + "", + sessionStateConfig, + newFileContents, + deletedFiles, + testReferences, + testUploadId, + 0, + messenger, + ), + ) + doReturn(testConversationId).`when`(spySession).conversationId + doReturn(Unit).`when`(spySession).insertChanges(any(), any(), any(), any()) + + mockkObject(AmazonqTelemetry) + every { + AmazonqTelemetry.isAcceptedCodeChanges( + amazonqNumberOfFilesAccepted = 1.0, + amazonqConversationId = testConversationId, + enabled = true, + credentialStartUrl = any() + ) + } just runs + + // Accept first file: + controller.processFileClicked(IncomingFeatureDevMessage.FileClicked(testTabId, newFileContents[0].zipFilePath, "", "accept-change")) + + val newFileContentsCopy = newFileContents.toList() + newFileContentsCopy[0].rejected = false + newFileContentsCopy[0].changeApplied = true + coVerify { messenger.updateFileComponent(testTabId, newFileContents, deletedFiles, "") } + + mockitoVerify( + spySession, + times(1), + ).insertChanges(listOf(newFileContents[0]), listOf(), testReferences, messenger) + + // Does not continue automatically, because files are remaining: + mockitoVerify( + controller, + times(0), + ).insertCode(testTabId) + } + + @Test + fun `test processFileClicked automatically continues when last file is accepted`() = + runTest { + val featureDevService = mockk() + val repoContext = mock() + val sessionStateConfig = SessionStateConfig(testConversationId, repoContext, featureDevService) + + whenever(featureDevClient.createTaskAssistConversation()).thenReturn(exampleCreateTaskAssistConversationResponse) + whenever(chatSessionStorage.getSession(any(), any())).thenReturn(spySession) + whenever(spySession.sessionState).thenReturn( + PrepareCodeGenerationState( + testTabId, + CancellationTokenSource(), + "", + sessionStateConfig, + newFileContents, + deletedFiles, + testReferences, + testUploadId, + 0, + messenger, + ), + ) + doReturn(testConversationId).`when`(spySession).conversationId + doReturn(Unit).`when`(spySession).insertChanges(any(), any(), any(), any()) + + mockkObject(AmazonqTelemetry) + every { + AmazonqTelemetry.isAcceptedCodeChanges( + amazonqNumberOfFilesAccepted = 1.0, + amazonqConversationId = testConversationId, + enabled = true, + credentialStartUrl = any() + ) + } just runs + + val newFileContentsCopy = newFileContents.toList() + newFileContentsCopy[0].rejected = false + newFileContentsCopy[0].changeApplied = true + newFileContentsCopy[1].rejected = false + newFileContentsCopy[1].changeApplied = true + deletedFiles[0].rejected = false + deletedFiles[0].changeApplied = true + + // This is simulating the file already being an accepted state, and accept-change being called redundantly. This is necessary because of the test + // setup, which should be fixed to avoid heavy-handed mocking of the session state (so that we can see the session state be incrementally updated). + deletedFiles[1].rejected = false + deletedFiles[1].changeApplied = true + + // When the last file is accepted: + controller.processFileClicked(IncomingFeatureDevMessage.FileClicked(testTabId, deletedFiles[1].zipFilePath, "", "accept-change")) + + // We auto-continue to the next step with a noop insertCode call: + mockitoVerify( + controller, + times(1), + ).insertCode(testTabId) + } + @Test fun `test modifyDefaultSourceFolder customer does not select a folder`() = runTest { diff --git a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/CodeGenerationStateTest.kt b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/CodeGenerationStateTest.kt index e488d5e473..d655ffc60e 100644 --- a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/CodeGenerationStateTest.kt +++ b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/CodeGenerationStateTest.kt @@ -84,10 +84,10 @@ class CodeGenerationStateTest : FeatureDevTestBase() { val nextState = actual.nextState as PrepareCodeGenerationState assertThat(nextState.phase).isEqualTo(SessionStatePhase.CODEGEN) assertThat(nextState.filePaths).isEqualTo( - listOf(NewFileZipInfo("test.ts", "This is a comment", false)), + listOf(NewFileZipInfo("test.ts", "This is a comment", rejected = false, changeApplied = false)), ) assertThat(nextState.deletedFiles).isEqualTo( - listOf(DeletedFileInfo("deleted.ts", false)), + listOf(DeletedFileInfo("deleted.ts", rejected = false, changeApplied = false)), ) assertThat(nextState.references).isEqualTo(testReferences) assertThat(nextState.codeGenerationRemainingIterationCount).isEqualTo(2) diff --git a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/SessionTest.kt b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/SessionTest.kt index 12d1fd7145..070abddb97 100644 --- a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/SessionTest.kt +++ b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/SessionTest.kt @@ -13,6 +13,7 @@ import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.runs import io.mockk.verify +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat import org.junit.Before @@ -75,13 +76,15 @@ class SessionTest : FeatureDevTestBase() { every { resolveAndDeleteFile(any(), any()) } just runs every { resolveAndCreateOrUpdateFile(any(), any(), any()) } just runs - val mockNewFile = listOf(NewFileZipInfo("test.ts", "testContent", false)) - val mockDeletedFile = listOf(DeletedFileInfo("deletedTest.ts", false)) + val mockNewFile = listOf(NewFileZipInfo("test.ts", "testContent", rejected = false, changeApplied = false)) + val mockDeletedFile = listOf(DeletedFileInfo("deletedTest.ts", rejected = false, changeApplied = false)) session.context.selectedSourceFolder = mock() whenever(session.context.selectedSourceFolder.toNioPath()).thenReturn(Path("")) - session.insertChanges(mockNewFile, mockDeletedFile, emptyList()) + runBlocking { + session.insertChanges(mockNewFile, mockDeletedFile, emptyList(), messenger) + } verify(exactly = 1) { resolveAndDeleteFile(any(), "deletedTest.ts") } verify(exactly = 1) { resolveAndCreateOrUpdateFile(any(), "test.ts", "testContent") } diff --git a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/apps/featureDevChatConnector.ts b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/apps/featureDevChatConnector.ts index 711569f65a..bfef0844e0 100644 --- a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/apps/featureDevChatConnector.ts +++ b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/apps/featureDevChatConnector.ts @@ -20,6 +20,7 @@ export interface ConnectorProps { onMessageReceived?: (tabID: string, messageData: any, needToShowAPIDocsTab: boolean) => void onAsyncEventProgress: (tabID: string, inProgress: boolean, message: string) => void onChatAnswerReceived?: (tabID: string, message: ChatItem) => void + onChatAnswerUpdated?: (tabID: string, message: ChatItem) => void sendFeedback?: (tabId: string, feedbackPayload: FeedbackPayload) => void | undefined onError: (tabID: string, message: string, title: string) => void onWarning: (tabID: string, message: string, title: string) => void @@ -28,7 +29,7 @@ export interface ConnectorProps { onUpdateAuthentication: (featureDevEnabled: boolean, codeTransformEnabled: boolean, authenticatingTabIDs: string[]) => void onNewTab: (tabType: TabType) => void tabsStorage: TabsStorage - onFileComponentUpdate: (tabID: string, filePaths: DiffTreeFileInfo[], deletedFiles: DiffTreeFileInfo[], messageId: string) => void + onFileComponentUpdate: (tabID: string, filePaths: DiffTreeFileInfo[], deletedFiles: DiffTreeFileInfo[], messageId: string, disableFileActions: boolean) => void } export class Connector { @@ -36,6 +37,7 @@ export class Connector { private readonly onError private readonly onWarning private readonly onChatAnswerReceived + private readonly onChatAnswerUpdated private readonly onAsyncEventProgress private readonly updatePlaceholder private readonly chatInputEnabled @@ -47,6 +49,7 @@ export class Connector { constructor(props: ConnectorProps) { this.sendMessageToExtension = props.sendMessageToExtension this.onChatAnswerReceived = props.onChatAnswerReceived + this.onChatAnswerUpdated = props.onChatAnswerUpdated this.onWarning = props.onWarning this.onError = props.onError this.onAsyncEventProgress = props.onAsyncEventProgress @@ -130,30 +133,15 @@ export class Connector { 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: ChatItem = this.createAnswer(messageData) this.onChatAnswerReceived(messageData.tabID, answer) } } private processCodeResultMessage = async (messageData: any): Promise => { if (this.onChatAnswerReceived !== undefined) { + const messageId = messageData.messageId ?? messageData.messageID ?? messageData.triggerID ?? messageData.conversationID ?? messageData.codeGenerationId + this.sendMessageToExtension({ command: 'store-code-result-message-id', tabID: messageData.tabID, messageId, tabType: 'featuredev' }) const actions = getActions([ ...messageData.filePaths, ...messageData.deletedFiles, @@ -165,7 +153,7 @@ export class Connector { canBeVoted: true, codeReference: messageData.references, // TODO get the backend to store a message id in addition to conversationID - messageId: messageData.messageID ?? messageData.triggerID ?? messageData.conversationID, + messageId, fileList: { rootFolderTitle: 'Changes', filePaths: (messageData.filePaths as DiffTreeFileInfo[]).map(path => path.zipFilePath), @@ -200,9 +188,36 @@ export class Connector { return } + 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, + } + } + handleMessageReceive = async (messageData: any): Promise => { if (messageData.type === 'updateFileComponent') { - this.onFileComponentUpdate(messageData.tabID, messageData.filePaths, messageData.deletedFiles, messageData.messageId) + this.onFileComponentUpdate(messageData.tabID, messageData.filePaths, messageData.deletedFiles, messageData.messageId, messageData.disableFileActions) + return + } + if (messageData.type === 'updateChatAnswer') { + const answer = this.createAnswer(messageData) + this.onChatAnswerUpdated?.(messageData.tabID, answer) return } if (messageData.type === 'errorMessage') { diff --git a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/commands.ts b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/commands.ts index c1405a1a6e..20ad900e1e 100644 --- a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/commands.ts +++ b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/commands.ts @@ -44,5 +44,6 @@ type MessageCommand = | 'codetransform-pom-file-open-click' | 'file-click' | 'open-settings' + | 'store-code-result-message-id' export type ExtensionMessage = Record & { command: MessageCommand } diff --git a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/connector.ts b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/connector.ts index cb5ecd1e08..ab7fc6ac20 100644 --- a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/connector.ts +++ b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/connector.ts @@ -58,7 +58,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/plugins/amazonq/mynah-ui/src/mynah-ui/ui/diffTree/actions.ts b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/diffTree/actions.ts index 1ae3b01bfb..f0ce6a6a2c 100644 --- a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/diffTree/actions.ts +++ b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/diffTree/actions.ts @@ -7,10 +7,16 @@ import { DiffTreeFileInfo } from './types' export function getDetails(filePaths: DiffTreeFileInfo[]): Record { return filePaths.reduce((details, filePath) => { - if (filePath.rejected) { + if (filePath.changeApplied) { + details[filePath.zipFilePath] = { + status: 'success', + label: 'Change accepted', + icon: MynahIcons.OK, + } + } else if (filePath.rejected) { details[filePath.zipFilePath] = { status: 'error', - label: 'File rejected', + label: 'Change rejected', icon: MynahIcons.CANCEL_CIRCLE, } } @@ -20,16 +26,36 @@ export function getDetails(filePaths: DiffTreeFileInfo[]): Record { return filePaths.reduce((actions, filePath) => { - actions[filePath.zipFilePath] = [filePath.rejected ? { - icon: MynahIcons.REVERT, - name: 'revert-rejection', - description: 'Revert rejection', - } : { - icon: MynahIcons.CANCEL_CIRCLE, - status: 'error', - name: 'reject-change', - description: 'Reject change', - }] + if (filePath.changeApplied) { + return actions + } + + actions[filePath.zipFilePath] = [] + + switch (filePath.rejected) { + case true: + actions[filePath.zipFilePath].push({ + icon: MynahIcons.REVERT, + name: 'revert-rejection', + description: 'Revert rejection', + }) + break + case false: + actions[filePath.zipFilePath].push({ + icon: MynahIcons.OK, + status: 'success', + name: 'accept-change', + description: 'Accept change', + }) + actions[filePath.zipFilePath].push({ + icon: MynahIcons.CANCEL_CIRCLE, + status: 'error', + name: 'reject-change', + description: 'Reject change', + }) + break + } + return actions }, {} as Record) } diff --git a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/diffTree/types.ts b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/diffTree/types.ts index a5776300a0..cd59f5f839 100644 --- a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/diffTree/types.ts +++ b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/diffTree/types.ts @@ -4,4 +4,5 @@ export type DiffTreeFileInfo = { zipFilePath: string rejected: boolean + changeApplied: boolean } diff --git a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/main.ts b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/main.ts index 32fa406c75..126d10f446 100644 --- a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/main.ts +++ b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/main.ts @@ -296,7 +296,8 @@ export const createMynahUI = (ideApi: any, featureDevInitEnabled: boolean, codeT tabID: string, filePaths: DiffTreeFileInfo[], deletedFiles: DiffTreeFileInfo[], - messageId: string + messageId: string, + disableFileActions: boolean = false ) => { const updateWith: Partial = { type: ChatItemType.ANSWER, @@ -304,8 +305,8 @@ export const createMynahUI = (ideApi: any, featureDevInitEnabled: boolean, codeT 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/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties index 3d8ea0a76d..dda5f4355e 100644 --- a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties +++ b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties @@ -80,8 +80,10 @@ amazonqFeatureDev.exception.upload_code=I'm sorry, I couldn't upload your worksp amazonqFeatureDev.exception.upload_url_expiry=I'm sorry, I wasn't able to generate code. A connection timed out or became unavailable. Please try again or check the following:\n\n- Exclude non-essential files in your workspace's `.gitignore`.\n\n- Check that your network connection is stable. amazonqFeatureDev.follow_instructions_for_authentication=Follow instructions to re-authenticate ... amazonqFeatureDev.follow_up.close_session=No, thanks +amazonqFeatureDev.follow_up.continue=Continue amazonqFeatureDev.follow_up.incorrect_source_folder=The folder you chose isn't in your open workspace folder. You can add this folder to your workspace, or choose a folder in your open workspace. -amazonqFeatureDev.follow_up.insert_code=Accept code +amazonqFeatureDev.follow_up.insert_all_code=Accept all code +amazonqFeatureDev.follow_up.insert_remaining_code=Accept remaining code amazonqFeatureDev.follow_up.modified_source_folder=Changed source root to: {0} amazonqFeatureDev.follow_up.modify_source_folder=Select files for context amazonqFeatureDev.follow_up.new_task=Yes, I have another task