Skip to content

Commit 301f2c5

Browse files
authored
feat(amazonq): read tool ui revamp (#2113)
* feat(amazonq): read tool ui revamp * feat(amazonq): read tool message revamp (#2049) * feat(amazonq): read tool message revamp * fix tests * feat: file search ui (#2078) * feat: file search ui * fix tests * fix integration tests * remove unnecessary type check * fix: use quotes instead of backticks * fix header update issue * fix integration test
1 parent c27dc49 commit 301f2c5

File tree

6 files changed

+163
-127
lines changed

6 files changed

+163
-127
lines changed

chat-client/src/client/mynahUi.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1353,10 +1353,15 @@ export const createMynahUi = (
13531353
fileTreeTitle: '',
13541354
hideFileCount: true,
13551355
details: toDetailsWithoutIcon(header.fileList.details),
1356+
renderAsPills:
1357+
!header.fileList.details ||
1358+
(Object.values(header.fileList.details).every(detail => !detail.changes) &&
1359+
(!header.buttons || !header.buttons.some(button => button.id === 'undo-changes')) &&
1360+
!header.status?.icon),
13561361
}
13571362
}
13581363
if (!isPartialResult) {
1359-
if (processedHeader) {
1364+
if (processedHeader && !message.header?.status) {
13601365
processedHeader.status = undefined
13611366
}
13621367
}
@@ -1369,7 +1374,8 @@ export const createMynahUi = (
13691374
processedHeader.buttons !== null &&
13701375
processedHeader.buttons.length > 0) ||
13711376
processedHeader.status !== undefined ||
1372-
processedHeader.icon !== undefined)
1377+
processedHeader.icon !== undefined ||
1378+
processedHeader.fileList !== undefined)
13731379

13741380
const padding =
13751381
message.type === 'tool' ? (fileList ? true : message.messageId?.endsWith('_permission')) : undefined
@@ -1380,8 +1386,10 @@ export const createMynahUi = (
13801386
// Adding this conditional check to show the stop message in the center.
13811387
const contentHorizontalAlignment: ChatItem['contentHorizontalAlignment'] = undefined
13821388

1383-
// If message.header?.status?.text is Stopped or Rejected or Ignored or Completed etc.. card should be in disabled state.
1384-
const shouldMute = message.header?.status?.text !== undefined && message.header?.status?.text !== 'Completed'
1389+
// If message.header?.status?.text is Stopped or Rejected or Ignored etc.. card should be in disabled state.
1390+
const shouldMute =
1391+
message.header?.status?.text !== undefined &&
1392+
['Stopped', 'Rejected', 'Ignored', 'Failed', 'Error'].includes(message.header.status.text)
13851393

13861394
return {
13871395
body: message.body,

integration-tests/q-agentic-chat-server/src/tests/agenticChatInteg.test.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -169,11 +169,11 @@ describe('Q Agentic Chat Server Integration Tests', async () => {
169169

170170
expect(decryptedResult.additionalMessages).to.be.an('array')
171171
const fsReadMessage = decryptedResult.additionalMessages?.find(
172-
msg => msg.type === 'tool' && msg.fileList?.rootFolderTitle === '1 file read'
172+
msg => msg.type === 'tool' && msg.header?.body === '1 file read'
173173
)
174174
expect(fsReadMessage).to.exist
175175
const expectedPath = path.join(rootPath, 'test.py')
176-
const actualPaths = fsReadMessage?.fileList?.filePaths?.map(normalizePath) || []
176+
const actualPaths = fsReadMessage?.header?.fileList?.filePaths?.map(normalizePath) || []
177177
expect(actualPaths).to.include.members([normalizePath(expectedPath)])
178178
expect(fsReadMessage?.messageId?.startsWith('tooluse_')).to.be.true
179179
})
@@ -191,10 +191,10 @@ describe('Q Agentic Chat Server Integration Tests', async () => {
191191

192192
expect(decryptedResult.additionalMessages).to.be.an('array')
193193
const listDirectoryMessage = decryptedResult.additionalMessages?.find(
194-
msg => msg.type === 'tool' && msg.fileList?.rootFolderTitle === '1 directory listed'
194+
msg => msg.type === 'tool' && msg.header?.body === '1 directory listed'
195195
)
196196
expect(listDirectoryMessage).to.exist
197-
const actualPaths = listDirectoryMessage?.fileList?.filePaths?.map(normalizePath) || []
197+
const actualPaths = listDirectoryMessage?.header?.fileList?.filePaths?.map(normalizePath) || []
198198
expect(actualPaths).to.include.members([normalizePath(rootPath)])
199199
expect(listDirectoryMessage?.messageId?.startsWith('tooluse_')).to.be.true
200200
})
@@ -371,11 +371,12 @@ describe('Q Agentic Chat Server Integration Tests', async () => {
371371

372372
expect(decryptedResult.additionalMessages).to.be.an('array')
373373
const fileSearchMessage = decryptedResult.additionalMessages?.find(
374-
msg => msg.type === 'tool' && msg.fileList?.rootFolderTitle === '1 directory searched'
374+
msg => msg.type === 'tool' && msg.header?.body === 'Searched for "test" in '
375375
)
376376
expect(fileSearchMessage).to.exist
377377
expect(fileSearchMessage?.messageId?.startsWith('tooluse_')).to.be.true
378-
const actualPaths = fileSearchMessage?.fileList?.filePaths?.map(normalizePath) || []
378+
expect(fileSearchMessage?.header?.status?.text).to.equal('3 results found')
379+
const actualPaths = fileSearchMessage?.header?.fileList?.filePaths?.map(normalizePath) || []
379380
expect(actualPaths).to.include.members([normalizePath(rootPath)])
380381
})
381382
})

server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ describe('AgenticChatController', () => {
451451

452452
assert.deepStrictEqual(chatResult, {
453453
additionalMessages: [],
454-
body: '\n\nHello World!',
454+
body: '\nHello World!',
455455
messageId: 'mock-message-id',
456456
buttons: [],
457457
codeReference: [],
@@ -1150,7 +1150,7 @@ describe('AgenticChatController', () => {
11501150
sinon.assert.callCount(testFeatures.lsp.sendProgress, mockChatResponseList.length + 1) // response length + 1 loading messages
11511151
assert.deepStrictEqual(chatResult, {
11521152
additionalMessages: [],
1153-
body: '\n\nHello World!',
1153+
body: '\nHello World!',
11541154
messageId: 'mock-message-id',
11551155
codeReference: [],
11561156
buttons: [],
@@ -1169,7 +1169,7 @@ describe('AgenticChatController', () => {
11691169
sinon.assert.callCount(testFeatures.lsp.sendProgress, mockChatResponseList.length + 1) // response length + 1 loading message
11701170
assert.deepStrictEqual(chatResult, {
11711171
additionalMessages: [],
1172-
body: '\n\nHello World!',
1172+
body: '\nHello World!',
11731173
messageId: 'mock-message-id',
11741174
buttons: [],
11751175
codeReference: [],

server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts

Lines changed: 123 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ import { ExecuteBash, ExecuteBashParams } from './tools/executeBash'
169169
import { ExplanatoryParams, InvokeOutput, ToolApprovalException } from './tools/toolShared'
170170
import { validatePathBasic, validatePathExists, validatePaths as validatePathsSync } from './utils/pathValidation'
171171
import { GrepSearch, SanitizedRipgrepOutput } from './tools/grepSearch'
172-
import { FileSearch, FileSearchParams } from './tools/fileSearch'
172+
import { FileSearch, FileSearchParams, isFileSearchParams } from './tools/fileSearch'
173173
import { FsReplace, FsReplaceParams } from './tools/fsReplace'
174174
import { loggingUtils, timeoutUtils } from '@aws/lsp-core'
175175
import { diffLines } from 'diff'
@@ -1695,8 +1695,7 @@ export class AgenticChatController implements ChatHandlers {
16951695
// remove progress UI
16961696
await chatResultStream.removeResultBlockAndUpdateUI(progressPrefix + toolUse.toolUseId)
16971697

1698-
// fsRead and listDirectory write to an existing card and could show nothing in the current position
1699-
if (![FS_WRITE, FS_REPLACE, FS_READ, LIST_DIRECTORY].includes(toolUse.name)) {
1698+
if (![FS_WRITE, FS_REPLACE].includes(toolUse.name)) {
17001699
await this.#showUndoAllIfRequired(chatResultStream, session)
17011700
}
17021701
// fsWrite can take a long time, so we render fsWrite Explanatory upon partial streaming responses.
@@ -1911,10 +1910,19 @@ export class AgenticChatController implements ChatHandlers {
19111910
switch (toolUse.name) {
19121911
case FS_READ:
19131912
case LIST_DIRECTORY:
1913+
const readToolResult = await this.#processReadTool(toolUse, chatResultStream)
1914+
if (readToolResult) {
1915+
await chatResultStream.writeResultBlock(readToolResult)
1916+
}
1917+
break
19141918
case FILE_SEARCH:
1915-
const initialListDirResult = this.#processReadOrListOrSearch(toolUse, chatResultStream)
1916-
if (initialListDirResult) {
1917-
await chatResultStream.writeResultBlock(initialListDirResult)
1919+
if (isFileSearchParams(toolUse.input)) {
1920+
await this.#processFileSearchTool(
1921+
toolUse.input,
1922+
toolUse.toolUseId,
1923+
result,
1924+
chatResultStream
1925+
)
19181926
}
19191927
break
19201928
// no need to write tool result for listDir,fsRead,fileSearch into chat stream
@@ -2315,7 +2323,6 @@ export class AgenticChatController implements ChatHandlers {
23152323
}
23162324

23172325
const toolMsgId = toolUse.toolUseId!
2318-
const chatMsgId = chatResultStream.getResult().messageId
23192326
let headerEmitted = false
23202327

23212328
const initialHeader: ChatMessage['header'] = {
@@ -2353,13 +2360,6 @@ export class AgenticChatController implements ChatHandlers {
23532360
header: completedHeader,
23542361
})
23552362

2356-
await chatResultStream.writeResultBlock({
2357-
type: 'answer',
2358-
messageId: chatMsgId,
2359-
body: '',
2360-
header: undefined,
2361-
})
2362-
23632363
this.#stoppedToolUses.add(toolMsgId)
23642364
},
23652365
})
@@ -2877,70 +2877,135 @@ export class AgenticChatController implements ChatHandlers {
28772877
}
28782878
}
28792879

2880-
#processReadOrListOrSearch(toolUse: ToolUse, chatResultStream: AgenticChatResultStream): ChatMessage | undefined {
2881-
let messageIdToUpdate = toolUse.toolUseId!
2882-
const currentId = chatResultStream.getMessageIdToUpdateForTool(toolUse.name!)
2880+
async #processFileSearchTool(
2881+
toolInput: FileSearchParams,
2882+
toolUseId: string,
2883+
result: InvokeOutput,
2884+
chatResultStream: AgenticChatResultStream
2885+
): Promise<void> {
2886+
if (typeof result.output.content !== 'string') return
28832887

2884-
if (currentId) {
2885-
messageIdToUpdate = currentId
2886-
} else {
2887-
chatResultStream.setMessageIdToUpdateForTool(toolUse.name!, messageIdToUpdate)
2888+
const { queryName, path: inputPath } = toolInput
2889+
const resultCount = result.output.content
2890+
.split('\n')
2891+
.filter(line => line.trim().startsWith('[F]') || line.trim().startsWith('[D]')).length
2892+
2893+
const chatMessage: ChatMessage = {
2894+
type: 'tool',
2895+
messageId: toolUseId,
2896+
header: {
2897+
body: `Searched for "${queryName}" in `,
2898+
icon: 'search',
2899+
status: {
2900+
text: `${resultCount} result${resultCount !== 1 ? 's' : ''} found`,
2901+
},
2902+
fileList: {
2903+
filePaths: [inputPath],
2904+
details: {
2905+
[inputPath]: {
2906+
description: inputPath,
2907+
visibleName: path.basename(inputPath),
2908+
clickable: false,
2909+
},
2910+
},
2911+
},
2912+
},
28882913
}
2889-
let currentPaths = []
2914+
await chatResultStream.writeResultBlock(chatMessage)
2915+
}
2916+
2917+
async #processReadTool(
2918+
toolUse: ToolUse,
2919+
chatResultStream: AgenticChatResultStream
2920+
): Promise<ChatMessage | undefined> {
2921+
let currentPaths: string[] = []
28902922
if (toolUse.name === FS_READ) {
2891-
currentPaths = (toolUse.input as unknown as FsReadParams)?.paths
2923+
currentPaths = (toolUse.input as unknown as FsReadParams)?.paths || []
2924+
} else if (toolUse.name === LIST_DIRECTORY) {
2925+
const singlePath = (toolUse.input as unknown as ListDirectoryParams)?.path
2926+
if (singlePath) {
2927+
currentPaths = [singlePath]
2928+
}
2929+
} else if (toolUse.name === FILE_SEARCH) {
2930+
const queryName = (toolUse.input as unknown as FileSearchParams)?.queryName
2931+
if (queryName) {
2932+
currentPaths = [queryName]
2933+
}
28922934
} else {
2893-
currentPaths.push((toolUse.input as unknown as ListDirectoryParams | FileSearchParams)?.path)
2935+
return
28942936
}
28952937

2896-
if (!currentPaths) return
2938+
if (currentPaths.length === 0) return
28972939

2898-
for (const currentPath of currentPaths) {
2899-
const existingPaths = chatResultStream.getMessageOperation(messageIdToUpdate)?.filePaths || []
2900-
// Check if path already exists in the list
2901-
const isPathAlreadyProcessed = existingPaths.some(path => path.relativeFilePath === currentPath)
2902-
if (!isPathAlreadyProcessed) {
2903-
const currentFileDetail = {
2904-
relativeFilePath: currentPath,
2905-
lineRanges: [{ first: -1, second: -1 }],
2906-
}
2907-
chatResultStream.addMessageOperation(messageIdToUpdate, toolUse.name!, [
2908-
...existingPaths,
2909-
currentFileDetail,
2910-
])
2940+
// Check if the last message is the same tool type
2941+
const lastMessage = chatResultStream.getLastMessage()
2942+
const isSameToolType =
2943+
lastMessage?.type === 'tool' && lastMessage.header?.icon === this.#toolToIcon(toolUse.name)
2944+
2945+
let allPaths = currentPaths
2946+
2947+
if (isSameToolType && lastMessage.messageId) {
2948+
// Combine with existing paths and overwrite the last message
2949+
const existingPaths = lastMessage.header?.fileList?.filePaths || []
2950+
allPaths = [...existingPaths, ...currentPaths]
2951+
2952+
const blockId = chatResultStream.getMessageBlockId(lastMessage.messageId)
2953+
if (blockId !== undefined) {
2954+
// Create the updated message with combined paths
2955+
const updatedMessage = this.#createFileListToolMessage(toolUse, allPaths, lastMessage.messageId)
2956+
// Overwrite the existing block
2957+
await chatResultStream.overwriteResultBlock(updatedMessage, blockId)
2958+
return undefined // Don't return a message since we already wrote it
29112959
}
29122960
}
2961+
2962+
// Create new message with current paths
2963+
return this.#createFileListToolMessage(toolUse, allPaths, toolUse.toolUseId!)
2964+
}
2965+
2966+
#createFileListToolMessage(toolUse: ToolUse, filePaths: string[], messageId: string): ChatMessage {
2967+
const itemCount = filePaths.length
29132968
let title: string
2914-
const itemCount = chatResultStream.getMessageOperation(messageIdToUpdate)?.filePaths.length
2915-
const filePathsPushed = chatResultStream.getMessageOperation(messageIdToUpdate)?.filePaths ?? []
2916-
if (!itemCount) {
2969+
if (itemCount === 0) {
29172970
title = 'Gathering context'
29182971
} else {
29192972
title =
29202973
toolUse.name === FS_READ
29212974
? `${itemCount} file${itemCount > 1 ? 's' : ''} read`
2922-
: toolUse.name === FILE_SEARCH
2923-
? `${itemCount} ${itemCount === 1 ? 'directory' : 'directories'} searched`
2924-
: `${itemCount} ${itemCount === 1 ? 'directory' : 'directories'} listed`
2975+
: toolUse.name === LIST_DIRECTORY
2976+
? `${itemCount} ${itemCount === 1 ? 'directory' : 'directories'} listed`
2977+
: ''
29252978
}
29262979
const details: Record<string, FileDetails> = {}
2927-
for (const item of filePathsPushed) {
2928-
details[item.relativeFilePath] = {
2929-
lineRanges: item.lineRanges,
2930-
description: item.relativeFilePath,
2980+
for (const filePath of filePaths) {
2981+
details[filePath] = {
2982+
description: filePath,
2983+
visibleName: path.basename(filePath),
2984+
clickable: toolUse.name === FS_READ,
29312985
}
29322986
}
2933-
2934-
const fileList: FileList = {
2935-
rootFolderTitle: title,
2936-
filePaths: filePathsPushed.map(item => item.relativeFilePath),
2937-
details,
2938-
}
29392987
return {
29402988
type: 'tool',
2941-
fileList,
2942-
messageId: messageIdToUpdate,
2943-
body: '',
2989+
header: {
2990+
body: title,
2991+
icon: this.#toolToIcon(toolUse.name),
2992+
fileList: {
2993+
filePaths,
2994+
details,
2995+
},
2996+
},
2997+
messageId,
2998+
}
2999+
}
3000+
3001+
#toolToIcon(toolName: string | undefined): string | undefined {
3002+
switch (toolName) {
3003+
case FS_READ:
3004+
return 'eye'
3005+
case LIST_DIRECTORY:
3006+
return 'check-list'
3007+
default:
3008+
return undefined
29443009
}
29453010
}
29463011

@@ -2956,14 +3021,7 @@ export class AgenticChatController implements ChatHandlers {
29563021
return undefined
29573022
}
29583023

2959-
let messageIdToUpdate = toolUse.toolUseId!
2960-
const currentId = chatResultStream.getMessageIdToUpdateForTool(toolUse.name!)
2961-
2962-
if (currentId) {
2963-
messageIdToUpdate = currentId
2964-
} else {
2965-
chatResultStream.setMessageIdToUpdateForTool(toolUse.name!, messageIdToUpdate)
2966-
}
3024+
const messageIdToUpdate = toolUse.toolUseId!
29673025

29683026
// Extract search results from the tool output
29693027
const output = result.output.content as SanitizedRipgrepOutput

0 commit comments

Comments
 (0)