Skip to content

Commit cc41a7f

Browse files
feat(amazonq): Add new Preview UX for code diff in agenti chat (aws#6932)
## Problem - In existing UX, user can not see the changes preview. ## Solution - In this updated UI, user can see the code changes and can click on accept/reject accordingly. ![image](https://github.com/user-attachments/assets/6fa769b1-1214-4c1a-8b8e-ec836db72786) - UI view after click on accept/reject accordingly: ![Screenshot 2025-04-03 at 11 31 15 AM](https://github.com/user-attachments/assets/b1c00661-e670-4ac1-80e6-6d853595671a) ![Screenshot 2025-04-03 at 11 29 44 AM](https://github.com/user-attachments/assets/8a492ea8-ec0d-4010-a6f8-0c292350a5d1) ## TODO: - Quickly try to raise a followup PR to Refactor the `fsWrite.ts` file `queueDescription` method to use existing diffLines and [diff library](https://www.npmjs.com/package/diff) to get the diff preview. or reuse existing diff view logic in cwc. - Add number of chars and lines generated/removed in code diff preview. - Adding "void" to some tests in toolSHared.test.ts file for the current implementation but in the next followup PR I will fix this issue. --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license. --------- Co-authored-by: Tyrone Smith <[email protected]>
1 parent 90d3ec0 commit cc41a7f

File tree

7 files changed

+240
-48
lines changed

7 files changed

+240
-48
lines changed

packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
ChatItemButton,
99
ChatItemFormItem,
1010
ChatItemType,
11+
MynahIconsType,
1112
MynahUIDataModel,
1213
QuickActionCommand,
1314
} from '@aws/mynah-ui'
@@ -33,6 +34,7 @@ export class Connector extends BaseConnector {
3334
private readonly onContextCommandDataReceived
3435
private readonly onShowCustomForm
3536
private readonly onChatAnswerUpdated
37+
private chatItems: Map<string, Map<string, ChatItem>> = new Map() // tabId -> messageId -> ChatItem
3638

3739
override getTabType(): TabType {
3840
return 'cwc'
@@ -109,6 +111,10 @@ export class Connector extends BaseConnector {
109111
title: messageData.title,
110112
buttons: messageData.buttons ?? undefined,
111113
fileList: messageData.fileList ?? undefined,
114+
header: messageData.header ?? undefined,
115+
padding: messageData.padding ?? undefined,
116+
fullWidth: messageData.fullWidth ?? undefined,
117+
codeBlockActions: messageData.codeBlockActions ?? undefined,
112118
}
113119

114120
if (messageData.relatedSuggestions !== undefined) {
@@ -117,6 +123,10 @@ export class Connector extends BaseConnector {
117123
content: messageData.relatedSuggestions,
118124
}
119125
}
126+
127+
if (answer.messageId) {
128+
this.storeChatItem(messageData.tabID, answer.messageId, answer)
129+
}
120130
this.onChatAnswerReceived(messageData.tabID, answer, messageData)
121131

122132
// Exit the function if we received an answer from AI
@@ -147,13 +157,28 @@ export class Connector extends BaseConnector {
147157
: undefined,
148158
buttons: messageData.buttons ?? undefined,
149159
canBeVoted: messageData.canBeVoted ?? false,
160+
header: messageData.header ?? undefined,
161+
padding: messageData.padding ?? undefined,
162+
fullWidth: messageData.fullWidth ?? undefined,
163+
codeBlockActions: messageData.codeBlockActions ?? undefined,
150164
}
151165
this.onChatAnswerReceived(messageData.tabID, answer, messageData)
152166

153167
return
154168
}
155169
}
156170

171+
private storeChatItem(tabId: string, messageId: string, item: ChatItem): void {
172+
if (!this.chatItems.has(tabId)) {
173+
this.chatItems.set(tabId, new Map())
174+
}
175+
this.chatItems.get(tabId)?.set(messageId, { ...item })
176+
}
177+
178+
private getCurrentChatItem(tabId: string, messageId: string): ChatItem | undefined {
179+
return this.chatItems.get(tabId)?.get(messageId)
180+
}
181+
157182
processContextCommandData(messageData: any) {
158183
if (messageData.data) {
159184
this.onContextCommandDataReceived(messageData.data)
@@ -270,35 +295,39 @@ export class Connector extends BaseConnector {
270295
) {
271296
return
272297
}
298+
299+
// Can not assign body as "undefined" or "null" because both of these values will be overriden at main.ts in onChatAnswerUpdated
300+
// TODO: Refactor in next PR if necessary.
301+
const currentChatItem = this.getCurrentChatItem(tabId, messageId)
273302
const answer: ChatItem = {
274303
type: ChatItemType.ANSWER,
275304
messageId: messageId,
276305
buttons: [],
306+
body: undefined,
307+
header: currentChatItem?.header ? { ...currentChatItem.header } : {},
277308
}
278309
switch (action.id) {
279310
case 'accept-code-diff':
280-
answer.buttons = [
281-
{
282-
keepCardAfterClick: true,
283-
text: 'Accepted code',
284-
id: 'accepted-code-diff',
311+
if (answer.header) {
312+
answer.header.status = {
313+
icon: 'ok' as MynahIconsType,
314+
text: 'Accepted',
285315
status: 'success',
286-
position: 'outside',
287-
disabled: true,
288-
},
289-
]
316+
}
317+
answer.header.buttons = []
318+
answer.body = ' '
319+
}
290320
break
291321
case 'reject-code-diff':
292-
answer.buttons = [
293-
{
294-
keepCardAfterClick: true,
295-
text: 'Rejected code',
296-
id: 'rejected-code-diff',
322+
if (answer.header) {
323+
answer.header.status = {
324+
icon: 'cancel' as MynahIconsType,
325+
text: 'Rejected',
297326
status: 'error',
298-
position: 'outside',
299-
disabled: true,
300-
},
301-
]
327+
}
328+
answer.header.buttons = []
329+
answer.body = ' '
330+
}
302331
break
303332
case 'confirm-tool-use':
304333
answer.buttons = [
@@ -315,6 +344,12 @@ export class Connector extends BaseConnector {
315344
default:
316345
break
317346
}
347+
348+
if (currentChatItem && answer.messageId) {
349+
const updatedItem = { ...currentChatItem, ...answer }
350+
this.storeChatItem(tabId, answer.messageId, updatedItem)
351+
}
352+
318353
this.onChatAnswerUpdated(tabId, answer)
319354
}
320355

packages/core/src/amazonq/webview/ui/main.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ export const createMynahUI = (
331331
...(item.followUp !== undefined ? { followUp: item.followUp } : {}),
332332
...(item.footer !== undefined ? { footer: item.footer } : {}),
333333
...(item.canBeVoted !== undefined ? { canBeVoted: item.canBeVoted } : {}),
334+
...(item.header !== undefined ? { header: item.header } : {}),
334335
})
335336
} else {
336337
mynahUI.updateLastChatAnswer(tabID, {
@@ -339,6 +340,7 @@ export const createMynahUI = (
339340
...(item.followUp !== undefined ? { followUp: item.followUp } : {}),
340341
...(item.footer !== undefined ? { footer: item.footer } : {}),
341342
...(item.canBeVoted !== undefined ? { canBeVoted: item.canBeVoted } : {}),
343+
...(item.header !== undefined ? { header: item.header } : {}),
342344
})
343345
}
344346
},
@@ -352,8 +354,11 @@ export const createMynahUI = (
352354
...(item.relatedContent !== undefined ? { relatedContent: item.relatedContent } : {}),
353355
...(item.followUp !== undefined ? { followUp: item.followUp } : {}),
354356
...(item.fileList !== undefined ? { fileList: item.fileList } : {}),
355-
...(item.header !== undefined ? { header: item.header } : { header: undefined }),
356-
...(item.buttons !== undefined ? { buttons: item.buttons } : { buttons: undefined }),
357+
...(item.header !== undefined ? { header: item.header } : {}),
358+
...(item.buttons !== undefined ? { buttons: item.buttons } : {}),
359+
...(item.fullWidth !== undefined ? { fullWidth: item.fullWidth } : {}),
360+
...(item.padding !== undefined ? { padding: item.padding } : {}),
361+
...(item.codeBlockActions !== undefined ? { codeBlockActions: item.codeBlockActions } : {}),
357362
})
358363
if (
359364
item.messageId !== undefined &&

packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,12 @@ import { LspController } from '../../../../amazonq/lsp/lspController'
3939
import { extractCodeBlockLanguage } from '../../../../shared/markdown'
4040
import { extractAuthFollowUp } from '../../../../amazonq/util/authUtils'
4141
import { helpMessage } from '../../../../amazonq/webview/ui/texts/constants'
42-
import { ChatItemButton, ChatItemContent, ChatItemFormItem, MynahUIDataModel } from '@aws/mynah-ui'
42+
import { ChatItemButton, ChatItemContent, ChatItemFormItem, MynahIconsType, MynahUIDataModel } from '@aws/mynah-ui'
4343
import { ChatHistoryManager } from '../../../storages/chatHistory'
4444
import { ToolType, ToolUtils } from '../../../tools/toolUtils'
4545
import { ChatStream } from '../../../tools/chatStream'
46-
import path from 'path'
4746
import { getWorkspaceForFile } from '../../../../shared/utilities/workspaceUtils'
47+
import path from 'path'
4848
import { CommandValidation } from '../../../tools/executeBash'
4949

5050
export type StaticTextResponseType = 'quick-action-help' | 'onboarding-help' | 'transform' | 'help'
@@ -224,7 +224,7 @@ export class Messenger {
224224
const validation = ToolUtils.requiresAcceptance(tool)
225225

226226
const chatStream = new ChatStream(this, tabID, triggerID, toolUse, validation)
227-
ToolUtils.queueDescription(tool, chatStream)
227+
await ToolUtils.queueDescription(tool, chatStream)
228228

229229
if (!validation.requiresAcceptance) {
230230
// Need separate id for read tool and safe bash command execution as 'confirm-tool-use' id is required to change button status from `Confirm` to `Confirmed` state in cwChatConnector.ts which will impact generic tool execution.
@@ -442,35 +442,44 @@ export class Messenger {
442442
buttons.push({
443443
id: 'confirm-tool-use',
444444
text: 'Confirm',
445-
position: 'outside',
446445
status: 'info',
447446
})
448447

449448
if (validation.warning) {
450449
message = validation.warning + message
451450
}
452451
} else if (toolUse?.name === ToolType.FsWrite) {
453-
// FileList
454452
const absoluteFilePath = (toolUse?.input as any).path
455453
const projectPath = getWorkspaceForFile(absoluteFilePath)
456454
const relativePath = projectPath ? path.relative(projectPath, absoluteFilePath) : absoluteFilePath
455+
// FileList
457456
fileList = {
458-
fileTreeTitle: 'Code suggestions',
459-
rootFolderTitle: path.basename(projectPath ?? 'Default'),
457+
fileTreeTitle: '',
458+
hideFileCount: true,
460459
filePaths: [relativePath],
460+
details: {
461+
[relativePath]: {
462+
// eslint-disable-next-line unicorn/no-null
463+
icon: null,
464+
label: 'Created',
465+
changes: {
466+
added: 36,
467+
deleted: 0,
468+
total: 36,
469+
},
470+
},
471+
},
461472
}
462473
// Buttons
463474
buttons.push({
464475
id: 'reject-code-diff',
465-
text: 'Reject',
466-
position: 'outside',
467-
status: 'error',
476+
status: 'clear',
477+
icon: 'cancel' as MynahIconsType,
468478
})
469479
buttons.push({
470480
id: 'accept-code-diff',
471-
text: 'Accept',
472-
position: 'outside',
473-
status: 'success',
481+
status: 'clear',
482+
icon: 'ok' as MynahIconsType,
474483
})
475484
}
476485

@@ -488,8 +497,16 @@ export class Messenger {
488497
codeBlockLanguage: undefined,
489498
contextList: undefined,
490499
canBeVoted: false,
491-
buttons,
492-
fileList,
500+
buttons: toolUse?.name === ToolType.FsWrite ? undefined : buttons,
501+
fullWidth: toolUse?.name === ToolType.FsWrite,
502+
padding: !(toolUse?.name === ToolType.FsWrite),
503+
header:
504+
toolUse?.name === ToolType.FsWrite
505+
? { icon: 'code-block' as MynahIconsType, buttons: buttons, fileList: fileList }
506+
: undefined,
507+
codeBlockActions:
508+
// eslint-disable-next-line unicorn/no-null, prettier/prettier
509+
toolUse?.name === ToolType.FsWrite ? { 'insert-to-cursor': null, copy: null } : undefined,
493510
},
494511
tabID
495512
)

packages/core/src/codewhispererChat/tools/fsWrite.ts

Lines changed: 107 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { getLogger } from '../../shared/logger/logger'
88
import vscode from 'vscode'
99
import { fs } from '../../shared/fs/fs'
1010
import { Writable } from 'stream'
11-
import path from 'path'
1211

1312
interface BaseParams {
1413
path: string
@@ -70,11 +69,113 @@ export class FsWrite {
7069
}
7170
}
7271

73-
public queueDescription(updates: Writable): void {
74-
const fileName = path.basename(this.params.path)
75-
updates.write(
76-
`Please see the generated code below for \`${fileName}\`. Click on the file to review the changes in the code editor and select Accept or Reject.`
77-
)
72+
// TODO: Refactor the fsWrite.ts file "queueDescription" method to use existing diffLines and diff library to get the diff preview. or reuse existing diff view logic in cwchat. This will be part of next PR.
73+
private showStrReplacePreview(oldStr: string, newStr: string): string {
74+
// Split both strings into arrays of lines
75+
const oldStrLines = oldStr.split('\n')
76+
const newStrLines = newStr.split('\n')
77+
let result = ''
78+
79+
// If strings are identical, return empty string
80+
if (oldStr === newStr) {
81+
return result
82+
}
83+
84+
let oldLineIndex = 0
85+
let newLineIndex = 0
86+
// Loop through both arrays until we've processed all lines
87+
while (oldLineIndex < oldStrLines.length || newLineIndex < newStrLines.length) {
88+
if (
89+
oldLineIndex < oldStrLines.length &&
90+
newLineIndex < newStrLines.length &&
91+
oldStrLines[oldLineIndex] === newStrLines[newLineIndex]
92+
) {
93+
// Line is unchanged - prefix with space
94+
result += ` ${oldStrLines[oldLineIndex]}\n`
95+
oldLineIndex++
96+
newLineIndex++
97+
} else {
98+
// Line is different
99+
if (oldLineIndex < oldStrLines.length) {
100+
// Remove line - prefix with minus
101+
result += `- ${oldStrLines[oldLineIndex]}\n`
102+
oldLineIndex++
103+
}
104+
if (newLineIndex < newStrLines.length) {
105+
// Add line - prefix with plus
106+
result += `+ ${newStrLines[newLineIndex]}\n`
107+
newLineIndex++
108+
}
109+
}
110+
}
111+
112+
return result
113+
}
114+
115+
private async showInsertPreview(path: string, insertLine: number, newStr: string): Promise<string> {
116+
const fileContent = await fs.readFileText(path)
117+
const lines = fileContent.split('\n')
118+
const startLine = Math.max(0, insertLine - 2)
119+
const endLine = Math.min(lines.length, insertLine + 3)
120+
121+
const contextLines: string[] = []
122+
123+
// Add lines before insertion point
124+
for (let index = startLine; index < insertLine; index++) {
125+
contextLines.push(` ${lines[index]}`)
126+
}
127+
128+
// Add the new line with a '+' prefix
129+
contextLines.push(`+ ${newStr}`)
130+
131+
// Add lines after insertion point
132+
for (let index = insertLine; index < endLine; index++) {
133+
contextLines.push(` ${lines[index]}`)
134+
}
135+
136+
return contextLines.join('\n')
137+
}
138+
139+
private async showAppendPreview(sanitizedPath: string, newStr: string) {
140+
const fileContent = await fs.readFileText(sanitizedPath)
141+
const needsNewline = fileContent.length !== 0 && !fileContent.endsWith('\n')
142+
143+
let contentToAppend = newStr
144+
if (needsNewline) {
145+
contentToAppend = '\n' + contentToAppend
146+
}
147+
148+
// Get the last 3 lines from existing content for better UX
149+
const lines = fileContent.split('\n')
150+
const linesForContext = lines.slice(-3)
151+
152+
return `${linesForContext.join('\n')}\n+ ${contentToAppend.trim()}`
153+
}
154+
155+
public async queueDescription(updates: Writable): Promise<void> {
156+
switch (this.params.command) {
157+
case 'create':
158+
updates.write(`\`\`\`diff-typescript
159+
${'+' + this.params.fileText?.replace(/\n/g, '\n+')}
160+
`)
161+
break
162+
case 'strReplace':
163+
updates.write(`\`\`\`diff-typescript
164+
${this.showStrReplacePreview(this.params.oldStr, this.params.newStr)}
165+
\`\`\`
166+
`)
167+
break
168+
case 'insert':
169+
updates.write(`\`\`\`diff-typescript
170+
${await this.showInsertPreview(this.params.path, this.params.insertLine, this.params.newStr)}
171+
\`\`\``)
172+
break
173+
case 'append':
174+
updates.write(`\`\`\`diff-typescript
175+
${await this.showAppendPreview(this.params.path, this.params.newStr)}
176+
\`\`\``)
177+
break
178+
}
78179
updates.end()
79180
}
80181

0 commit comments

Comments
 (0)