Skip to content

Commit a53642e

Browse files
authored
feat(amazonq): Implement file-level acceptance for /dev. (#5063)
Provides the feature for a user to accept individual generated files, and add them to their project.
1 parent 6a1739d commit a53642e

File tree

21 files changed

+479
-105
lines changed

21 files changed

+479
-105
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type" : "feature",
3+
"description" : "Amazon Q /dev: Add an action to accept individual files"
4+
}

plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/FeatureDevApp.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ class FeatureDevApp : AmazonQApp {
4242
"insert_code_at_cursor_position" to IncomingFeatureDevMessage.InsertCodeAtCursorPosition::class,
4343
"open-diff" to IncomingFeatureDevMessage.OpenDiff::class,
4444
"file-click" to IncomingFeatureDevMessage.FileClicked::class,
45-
"stop-response" to IncomingFeatureDevMessage.StopResponse::class
45+
"stop-response" to IncomingFeatureDevMessage.StopResponse::class,
46+
"store-code-result-message-id" to IncomingFeatureDevMessage.StoreMessageIdMessage::class
4647
)
4748

4849
scope.launch {
@@ -84,6 +85,7 @@ class FeatureDevApp : AmazonQApp {
8485
is IncomingFeatureDevMessage.OpenDiff -> inboundAppMessagesHandler.processOpenDiff(message)
8586
is IncomingFeatureDevMessage.FileClicked -> inboundAppMessagesHandler.processFileClicked(message)
8687
is IncomingFeatureDevMessage.StopResponse -> inboundAppMessagesHandler.processStopMessage(message)
88+
is IncomingFeatureDevMessage.StoreMessageIdMessage -> inboundAppMessagesHandler.processStoreCodeResultMessageId(message)
8789
}
8890
}
8991

plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/InboundAppMessagesHandler.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package software.aws.toolkits.jetbrains.services.amazonqFeatureDev
66
import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.messages.IncomingFeatureDevMessage
77

88
interface InboundAppMessagesHandler {
9+
910
suspend fun processPromptChatMessage(message: IncomingFeatureDevMessage.ChatPrompt)
1011
suspend fun processNewTabCreatedMessage(message: IncomingFeatureDevMessage.NewTabCreated)
1112
suspend fun processTabRemovedMessage(message: IncomingFeatureDevMessage.TabRemoved)
@@ -18,4 +19,5 @@ interface InboundAppMessagesHandler {
1819
suspend fun processOpenDiff(message: IncomingFeatureDevMessage.OpenDiff)
1920
suspend fun processFileClicked(message: IncomingFeatureDevMessage.FileClicked)
2021
suspend fun processStopMessage(message: IncomingFeatureDevMessage.StopResponse)
22+
suspend fun processStoreCodeResultMessageId(message: IncomingFeatureDevMessage.StoreMessageIdMessage)
2123
}

plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevController.kt

Lines changed: 123 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ package software.aws.toolkits.jetbrains.services.amazonqFeatureDev.controller
55

66
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
77
import com.intellij.diff.DiffContentFactory
8-
import com.intellij.diff.DiffManager
8+
import com.intellij.diff.chains.SimpleDiffRequestChain
99
import com.intellij.diff.contents.EmptyContent
10+
import com.intellij.diff.editor.ChainDiffVirtualFile
11+
import com.intellij.diff.editor.DiffEditorTabFilesManager
1012
import com.intellij.diff.requests.SimpleDiffRequest
1113
import com.intellij.ide.BrowserUtil
1214
import com.intellij.openapi.application.runInEdt
@@ -17,6 +19,7 @@ import com.intellij.openapi.fileEditor.FileEditorManager
1719
import com.intellij.openapi.project.Project
1820
import com.intellij.openapi.vfs.VfsUtil
1921
import com.intellij.openapi.wm.ToolWindowManager
22+
import kotlinx.coroutines.delay
2023
import kotlinx.coroutines.withContext
2124
import software.amazon.awssdk.services.toolkittelemetry.model.Sentiment
2225
import software.aws.toolkits.core.utils.debug
@@ -65,7 +68,10 @@ import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.session.Prepar
6568
import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.session.Session
6669
import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.session.SessionStatePhase
6770
import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.storage.ChatSessionStorage
71+
import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.InsertAction
72+
import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.getFollowUpOptions
6873
import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.selectFolder
74+
import software.aws.toolkits.jetbrains.services.codewhisperer.util.content
6975
import software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry.FeedbackComment
7076
import software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry.getStartUrl
7177
import software.aws.toolkits.jetbrains.services.telemetry.TelemetryService
@@ -86,13 +92,19 @@ class FeatureDevController(
8692
val messenger = context.messagesFromAppToUi
8793
val toolWindow = ToolWindowManager.getInstance(context.project).getToolWindow(AmazonQToolWindowFactory.WINDOW_ID)
8894

95+
private val diffVirtualFiles = mutableMapOf<String, ChainDiffVirtualFile>()
96+
8997
override suspend fun processPromptChatMessage(message: IncomingFeatureDevMessage.ChatPrompt) {
9098
handleChat(
9199
tabId = message.tabId,
92100
message = message.chatMessage
93101
)
94102
}
95103

104+
override suspend fun processStoreCodeResultMessageId(message: IncomingFeatureDevMessage.StoreMessageIdMessage) {
105+
storeCodeResultMessageId(message)
106+
}
107+
96108
override suspend fun processStopMessage(message: IncomingFeatureDevMessage.StopResponse) {
97109
handleStopMessage(message)
98110
}
@@ -126,6 +138,7 @@ class FeatureDevController(
126138

127139
override suspend fun processChatItemVotedMessage(message: IncomingFeatureDevMessage.ChatItemVotedMessage) {
128140
logger.debug { "$FEATURE_NAME: Processing ChatItemVotedMessage: $message" }
141+
this.disablePreviousFileList(message.tabId)
129142

130143
val session = chatSessionStorage.getSession(message.tabId, context.project)
131144
when (message.vote) {
@@ -192,6 +205,18 @@ class FeatureDevController(
192205
}
193206
}
194207

208+
private fun putDiff(filePath: String, request: SimpleDiffRequest) {
209+
// Close any existing diff and open a new diff, as the diff virtual file does not appear to allow replacing content directly:
210+
val existingDiff = diffVirtualFiles[filePath]
211+
if (existingDiff != null) {
212+
FileEditorManager.getInstance(context.project).closeFile(existingDiff)
213+
}
214+
215+
val newDiff = ChainDiffVirtualFile(SimpleDiffRequestChain(request), filePath)
216+
DiffEditorTabFilesManager.getInstance(context.project).showDiffFile(newDiff, true)
217+
diffVirtualFiles[filePath] = newDiff
218+
}
219+
195220
override suspend fun processOpenDiff(message: IncomingFeatureDevMessage.OpenDiff) {
196221
val session = getSessionInfo(message.tabId)
197222

@@ -223,9 +248,7 @@ class FeatureDevController(
223248
DiffContentFactory.getInstance().create(newFileContent)
224249
}
225250

226-
val request = SimpleDiffRequest(message.filePath, leftDiffContent, rightDiffContent, null, null)
227-
228-
DiffManager.getInstance().showDiff(project, request)
251+
putDiff(message.filePath, SimpleDiffRequest(message.filePath, leftDiffContent, rightDiffContent, null, null))
229252
}
230253
}
231254
else -> {
@@ -244,21 +267,81 @@ class FeatureDevController(
244267
val fileToUpdate = message.filePath
245268
val session = getSessionInfo(message.tabId)
246269
val messageId = message.messageId
270+
val action = message.actionName
247271

248272
var filePaths: List<NewFileZipInfo> = emptyList()
249273
var deletedFiles: List<DeletedFileInfo> = emptyList()
274+
var references: List<CodeReferenceGenerated> = emptyList()
250275
when (val state = session.sessionState) {
251276
is PrepareCodeGenerationState -> {
252277
filePaths = state.filePaths
253278
deletedFiles = state.deletedFiles
279+
references = state.references
254280
}
255281
}
256282

257-
// Mark the file as rejected or not depending on the previous state
258-
filePaths.find { it.zipFilePath == fileToUpdate }?.let { it.rejected = !it.rejected }
259-
deletedFiles.find { it.zipFilePath == fileToUpdate }?.let { it.rejected = !it.rejected }
283+
fun insertAction(): InsertAction =
284+
if (filePaths.all { it.changeApplied } && deletedFiles.all { it.changeApplied }) {
285+
InsertAction.AUTO_CONTINUE
286+
} else if (filePaths.all { it.changeApplied || it.rejected } && deletedFiles.all { it.changeApplied || it.rejected }) {
287+
InsertAction.CONTINUE
288+
} else if (filePaths.any { it.changeApplied || it.rejected } || deletedFiles.any { it.changeApplied || it.rejected }) {
289+
InsertAction.REMAINING
290+
} else {
291+
InsertAction.ALL
292+
}
293+
294+
val prevInsertAction = insertAction()
295+
296+
if (action == "accept-change") {
297+
session.insertChanges(
298+
filePaths = filePaths.filter { it.zipFilePath == fileToUpdate },
299+
deletedFiles = deletedFiles.filter { it.zipFilePath == fileToUpdate },
300+
references = references, // Add all references (not attributed per-file)
301+
messenger
302+
)
303+
304+
AmazonqTelemetry.isAcceptedCodeChanges(
305+
amazonqNumberOfFilesAccepted = 1.0,
306+
amazonqConversationId = session.conversationId,
307+
enabled = true,
308+
credentialStartUrl = getStartUrl(project = context.project)
309+
)
310+
} else {
311+
// Mark the file as rejected or not depending on the previous state
312+
filePaths.find { it.zipFilePath == fileToUpdate }?.let { it.rejected = !it.rejected }
313+
deletedFiles.find { it.zipFilePath == fileToUpdate }?.let { it.rejected = !it.rejected }
314+
}
260315

316+
// Update the state of the tree view:
261317
messenger.updateFileComponent(message.tabId, filePaths, deletedFiles, messageId)
318+
319+
// Then, if the accepted file is not a deletion, open a diff to show the changes are applied:
320+
if (action == "accept-change" && deletedFiles.none { it.zipFilePath == fileToUpdate }) {
321+
var pollAttempt = 0
322+
val pollDelayMs = 10L
323+
while (pollAttempt < 5) {
324+
val file = VfsUtil.findRelativeFile(message.filePath, session.context.selectedSourceFolder)
325+
// Wait for the file to be created and/or updated to the new content:
326+
if (file != null && file.content() == filePaths.find { it.zipFilePath == fileToUpdate }?.fileContent) {
327+
// Open a diff, showing the changes have been applied and the file now has identical left/right state:
328+
this.processOpenDiff(IncomingFeatureDevMessage.OpenDiff(message.tabId, fileToUpdate, false))
329+
break
330+
} else {
331+
pollAttempt++
332+
delay(pollDelayMs)
333+
}
334+
}
335+
}
336+
337+
val nextInsertAction = insertAction()
338+
if (nextInsertAction == InsertAction.AUTO_CONTINUE) {
339+
// Insert remaining changes (noop, as there are none), and advance to the next prompt:
340+
insertCode(message.tabId)
341+
} else if (nextInsertAction != prevInsertAction) {
342+
// Update the action displayed to the customer based on the current state:
343+
messenger.sendSystemPrompt(message.tabId, getFollowUpOptions(session.sessionState.phase, nextInsertAction))
344+
}
262345
}
263346

264347
private suspend fun newTabOpened(tabId: String) {
@@ -308,7 +391,8 @@ class FeatureDevController(
308391
session.sessionState.token?.cancel()
309392
}
310393
}
311-
private suspend fun insertCode(tabId: String) {
394+
395+
suspend fun insertCode(tabId: String) {
312396
var session: Session? = null
313397
try {
314398
session = getSessionInfo(tabId)
@@ -325,17 +409,22 @@ class FeatureDevController(
325409
}
326410
}
327411

412+
val rejectedFilesCount = filePaths.count { it.rejected } + deletedFiles.count { it.rejected }
413+
val acceptedFilesCount = filePaths.count { it.changeApplied } + filePaths.count { it.changeApplied }
414+
val remainingFilesCount = filePaths.count() + deletedFiles.count() - acceptedFilesCount - rejectedFilesCount
415+
328416
AmazonqTelemetry.isAcceptedCodeChanges(
329-
amazonqNumberOfFilesAccepted = (filePaths.filterNot { it.rejected }.size + deletedFiles.filterNot { it.rejected }.size) * 1.0,
417+
amazonqNumberOfFilesAccepted = remainingFilesCount.toDouble(),
330418
amazonqConversationId = session.conversationId,
331419
enabled = true,
332420
credentialStartUrl = getStartUrl(project = context.project)
333421
)
334422

335423
session.insertChanges(
336-
filePaths = filePaths.filterNot { it.rejected },
337-
deletedFiles = deletedFiles.filterNot { it.rejected },
338-
references = references
424+
filePaths = filePaths.filterNot { it.rejected || it.changeApplied },
425+
deletedFiles = deletedFiles.filterNot { it.rejected || it.changeApplied },
426+
references = references,
427+
messenger
339428
)
340429

341430
messenger.sendAnswer(
@@ -377,8 +466,11 @@ class FeatureDevController(
377466
}
378467

379468
private suspend fun newTask(tabId: String, isException: Boolean? = false) {
469+
this.disablePreviousFileList(tabId)
470+
380471
val session = getSessionInfo(tabId)
381472
val sessionLatency = System.currentTimeMillis() - session.sessionStartTime
473+
382474
AmazonqTelemetry.endChat(
383475
amazonqConversationId = session.conversationId,
384476
amazonqEndOfTheConversationLatency = sessionLatency.toDouble(),
@@ -399,6 +491,7 @@ class FeatureDevController(
399491
}
400492

401493
private suspend fun closeSession(tabId: String) {
494+
this.disablePreviousFileList(tabId)
402495
messenger.sendAnswer(
403496
tabId = tabId,
404497
messageType = FeatureDevMessageType.Answer,
@@ -503,7 +596,7 @@ class FeatureDevController(
503596
tabId = tabId,
504597
followUp = listOf(
505598
FollowUp(
506-
pillText = message("amazonqFeatureDev.follow_up.insert_code"),
599+
pillText = message("amazonqFeatureDev.follow_up.insert_all_code"),
507600
type = FollowUpTypes.INSERT_CODE,
508601
icon = FollowUpIcons.Ok,
509602
status = FollowUpStatusType.Success,
@@ -546,11 +639,28 @@ class FeatureDevController(
546639
}
547640
}
548641

642+
private suspend fun disablePreviousFileList(tabId: String) {
643+
val session = getSessionInfo(tabId)
644+
when (val sessionState = session.sessionState) {
645+
is PrepareCodeGenerationState -> {
646+
session.disableFileList(sessionState.filePaths, sessionState.deletedFiles, messenger)
647+
}
648+
}
649+
}
650+
651+
private fun storeCodeResultMessageId(message: IncomingFeatureDevMessage.StoreMessageIdMessage) {
652+
val tabId = message.tabId
653+
val session = getSessionInfo(tabId)
654+
session.storeCodeResultMessageId(message)
655+
}
656+
549657
private suspend fun handleChat(
550658
tabId: String,
551659
message: String,
552660
) {
553661
var session: Session? = null
662+
663+
this.disablePreviousFileList(tabId)
554664
try {
555665
logger.debug { "$FEATURE_NAME: Processing message: $message" }
556666
session = getSessionInfo(tabId)

plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevControllerExtensions.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.session.Prepar
2323
import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.session.Session
2424
import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.session.SessionState
2525
import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.CancellationTokenSource
26+
import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.InsertAction
2627
import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.getFollowUpOptions
2728
import software.aws.toolkits.jetbrains.utils.notifyInfo
2829
import software.aws.toolkits.resources.message
@@ -88,7 +89,7 @@ suspend fun FeatureDevController.onCodeGeneration(
8889
}
8990

9091
// Atm this is the only possible path as codegen is mocked to return empty.
91-
if (filePaths.size or deletedFiles.size == 0) {
92+
if (filePaths.isEmpty() && deletedFiles.isEmpty()) {
9293
messenger.sendAnswer(
9394
tabId = tabId,
9495
messageType = FeatureDevMessageType.Answer,
@@ -132,7 +133,7 @@ suspend fun FeatureDevController.onCodeGeneration(
132133
)
133134
}
134135

135-
messenger.sendSystemPrompt(tabId = tabId, followUp = getFollowUpOptions(session.sessionState.phase))
136+
messenger.sendSystemPrompt(tabId = tabId, followUp = getFollowUpOptions(session.sessionState.phase, InsertAction.ALL))
136137

137138
messenger.sendUpdatePlaceholder(tabId = tabId, newPlaceholder = message("amazonqFeatureDev.placeholder.after_code_generation"))
138139
} finally {

plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/messages/FeatureDevMessage.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ sealed interface IncomingFeatureDevMessage : FeatureDevBaseMessage {
2424
@JsonProperty("tabID") val tabId: String,
2525
) : IncomingFeatureDevMessage
2626

27+
data class StoreMessageIdMessage(
28+
@JsonProperty("tabID") val tabId: String,
29+
val command: String,
30+
val messageId: String?,
31+
) : IncomingFeatureDevMessage
32+
2733
data class NewTabCreated(
2834
val command: String,
2935
@JsonProperty("tabID") val tabId: String,
@@ -149,6 +155,7 @@ data class FileComponent(
149155
val filePaths: List<NewFileZipInfo>,
150156
val deletedFiles: List<DeletedFileInfo>,
151157
val messageId: String,
158+
val disableFileActions: Boolean = false,
152159
) : UiMessage(
153160
tabId = tabId,
154161
type = "updateFileComponent"
@@ -198,6 +205,7 @@ data class CodeResultMessage(
198205
val filePaths: List<NewFileZipInfo>,
199206
val deletedFiles: List<DeletedFileInfo>,
200207
val references: List<CodeReference>,
208+
val messageId: String?,
201209
) : UiMessage(
202210
tabId = tabId,
203211
type = "codeResultMessage"

0 commit comments

Comments
 (0)