From 1fc1fd7fdfaf2daed27cbec9480a60deae2a902a Mon Sep 17 00:00:00 2001 From: laileni Date: Thu, 10 Apr 2025 11:03:52 -0700 Subject: [PATCH 1/4] Reverting PR:#6975 --- .../webview/ui/apps/cwChatConnector.ts | 32 ----- .../core/src/amazonq/webview/ui/connector.ts | 2 +- packages/core/src/amazonq/webview/ui/main.ts | 67 +++++------ .../codewhispererChat/clients/chat/v0/chat.ts | 36 +----- .../controllers/chat/controller.ts | 16 +-- .../controllers/chat/messenger/messenger.ts | 111 ------------------ .../src/codewhispererChat/tools/chatStream.ts | 21 +--- .../src/codewhispererChat/tools/fsRead.ts | 19 ++- .../codewhispererChat/tools/listDirectory.ts | 6 +- .../view/connector/connector.ts | 8 -- 10 files changed, 59 insertions(+), 259 deletions(-) diff --git a/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts b/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts index ab175c1da6f..b52d913e7a2 100644 --- a/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts @@ -179,33 +179,6 @@ export class Connector extends BaseConnector { } } - private processToolMessage = async (messageData: any): Promise => { - if (this.onChatAnswerUpdated === undefined) { - return - } - const answer: CWCChatItem = { - type: messageData.messageType, - messageId: messageData.messageID ?? messageData.triggerID, - body: messageData.message, - followUp: messageData.followUps, - canBeVoted: messageData.canBeVoted ?? false, - codeReference: messageData.codeReference, - userIntent: messageData.contextList, - codeBlockLanguage: messageData.codeBlockLanguage, - contextList: messageData.contextList, - title: messageData.title, - buttons: messageData.buttons, - fileList: messageData.fileList, - header: messageData.header ?? undefined, - padding: messageData.padding ?? undefined, - fullWidth: messageData.fullWidth ?? undefined, - codeBlockActions: messageData.codeBlockActions ?? undefined, - rootFolderTitle: messageData.rootFolderTitle, - } - this.onChatAnswerUpdated(messageData.tabID, answer) - return - } - private storeChatItem(tabId: string, messageId: string, item: ChatItem): void { if (!this.chatItems.has(tabId)) { this.chatItems.set(tabId, new Map()) @@ -265,11 +238,6 @@ export class Connector extends BaseConnector { return } - if (messageData.type === 'toolMessage') { - await this.processToolMessage(messageData) - return - } - if (messageData.type === 'editorContextCommandMessage') { await this.processEditorContextCommandMessage(messageData) return diff --git a/packages/core/src/amazonq/webview/ui/connector.ts b/packages/core/src/amazonq/webview/ui/connector.ts index 04a740624f1..9ed46f8f58d 100644 --- a/packages/core/src/amazonq/webview/ui/connector.ts +++ b/packages/core/src/amazonq/webview/ui/connector.ts @@ -78,7 +78,7 @@ export interface ConnectorProps { sendMessageToExtension: (message: ExtensionMessage) => void onMessageReceived?: (tabID: string, messageData: any, needToShowAPIDocsTab: boolean) => void onRunTestMessageReceived?: (tabID: string, showRunTestMessage: boolean) => void - onChatAnswerUpdated?: (tabID: string, message: CWCChatItem) => void + onChatAnswerUpdated?: (tabID: string, message: ChatItem) => void onChatAnswerReceived?: (tabID: string, message: ChatItem, messageData: any) => void onWelcomeFollowUpClicked: (tabID: string, welcomeFollowUpType: WelcomeFollowupType) => void onAsyncEventProgress: (tabID: string, inProgress: boolean, message: string | undefined) => void diff --git a/packages/core/src/amazonq/webview/ui/main.ts b/packages/core/src/amazonq/webview/ui/main.ts index 3204ebe65b7..580229d3ee2 100644 --- a/packages/core/src/amazonq/webview/ui/main.ts +++ b/packages/core/src/amazonq/webview/ui/main.ts @@ -100,41 +100,6 @@ export const createMynahUI = ( welcomeCount += 1 } - /** - * Creates a file list header from context list - * @param contextList List of file contexts - * @param rootFolderTitle Title for the root folder - * @returns Header object with file list - */ - const createFileListHeader = (contextList: any[], rootFolderTitle?: string) => { - return { - fileList: { - fileTreeTitle: '', - filePaths: contextList.map((file) => file.relativeFilePath), - rootFolderTitle: rootFolderTitle, - flatList: true, - collapsed: true, - hideFileCount: true, - details: Object.fromEntries( - contextList.map((file) => [ - file.relativeFilePath, - { - label: file.lineRanges - .map((range: { first: number; second: number }) => - range.first === -1 || range.second === -1 - ? '' - : `line ${range.first} - ${range.second}` - ) - .join(', '), - description: file.relativeFilePath, - clickable: true, - }, - ]) - ), - }, - } - } - // Adding the first tab as CWC tab tabsStorage.addTab({ id: 'tab-1', @@ -381,11 +346,8 @@ export const createMynahUI = ( sendMessageToExtension: (message) => { ideApi.postMessage(message) }, - onChatAnswerUpdated: (tabID: string, item: CWCChatItem) => { + onChatAnswerUpdated: (tabID: string, item: ChatItem) => { if (item.messageId !== undefined) { - if (item.contextList !== undefined && item.contextList.length > 0) { - item.header = createFileListHeader(item.contextList, item.rootFolderTitle) - } mynahUI.updateChatAnswerWithMessageId(tabID, item.messageId, { ...(item.body !== undefined ? { body: item.body } : {}), ...(item.buttons !== undefined ? { buttons: item.buttons } : {}), @@ -447,7 +409,32 @@ export const createMynahUI = ( } if (item.contextList !== undefined && item.contextList.length > 0) { - item.header = createFileListHeader(item.contextList, item.rootFolderTitle) + item.header = { + fileList: { + fileTreeTitle: '', + filePaths: item.contextList.map((file) => file.relativeFilePath), + rootFolderTitle: item.rootFolderTitle, + flatList: true, + collapsed: true, + hideFileCount: true, + details: Object.fromEntries( + item.contextList.map((file) => [ + file.relativeFilePath, + { + label: file.lineRanges + .map((range) => + range.first === -1 || range.second === -1 + ? '' + : `line ${range.first} - ${range.second}` + ) + .join(', '), + description: file.relativeFilePath, + clickable: true, + }, + ]) + ), + }, + } } if ( diff --git a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts index 10409be4ada..53551b254f5 100644 --- a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts +++ b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts @@ -14,7 +14,7 @@ import { ToolkitError } from '../../../../shared/errors' import { createCodeWhispererChatStreamingClient } from '../../../../shared/clients/codewhispererChatClient' import { createQDeveloperStreamingClient } from '../../../../shared/clients/qDeveloperChatClient' import { UserWrittenCodeTracker } from '../../../../codewhisperer/tracker/userWrittenCodeTracker' -import { DocumentReference, PromptMessage } from '../../../controllers/chat/model' +import { PromptMessage } from '../../../controllers/chat/model' import { FsWriteBackup } from '../../../../codewhispererChat/tools/fsWrite' export type ToolUseWithError = { @@ -30,10 +30,8 @@ export class ChatSession { * _readFiles = list of files read from the project to gather context before generating response. * _showDiffOnFileWrite = Controls whether to show diff view (true) or file context view (false) to the user * _context = Additional context to be passed to the LLM for generating the response - * _messageIdToUpdate = messageId of a chat message to be updated, used for reducing consecutive tool messages */ - private _readFiles: DocumentReference[] = [] - private _readFolders: DocumentReference[] = [] + private _readFiles: string[] = [] private _toolUseWithError: ToolUseWithError | undefined private _showDiffOnFileWrite: boolean = false private _context: PromptMessage['context'] @@ -43,8 +41,6 @@ export class ChatSession { * True if messages from local history have been sent to session. */ localHistoryHydrated: boolean = false - private _messageIdToUpdate: string | undefined - private _messageIdToUpdateListDirectory: string | undefined contexts: Map = new Map() // TODO: doesn't handle the edge case when two files share the same relativePath string but from different root @@ -53,21 +49,6 @@ export class ChatSession { public get sessionIdentifier(): string | undefined { return this.sessionId } - public get messageIdToUpdate(): string | undefined { - return this._messageIdToUpdate - } - - public setMessageIdToUpdate(messageId: string | undefined) { - this._messageIdToUpdate = messageId - } - - public get messageIdToUpdateListDirectory(): string | undefined { - return this._messageIdToUpdateListDirectory - } - - public setMessageIdToUpdateListDirectory(messageId: string | undefined) { - this._messageIdToUpdateListDirectory = messageId - } public get pairProgrammingModeOn(): boolean { return this._pairProgrammingModeOn @@ -114,30 +95,21 @@ export class ChatSession { public setSessionID(id?: string) { this.sessionId = id } - public get readFiles(): DocumentReference[] { + public get readFiles(): string[] { return this._readFiles } - public get readFolders(): DocumentReference[] { - return this._readFolders - } public get showDiffOnFileWrite(): boolean { return this._showDiffOnFileWrite } public setShowDiffOnFileWrite(value: boolean) { this._showDiffOnFileWrite = value } - public addToReadFiles(filePath: DocumentReference) { + public addToReadFiles(filePath: string) { this._readFiles.push(filePath) } public clearListOfReadFiles() { this._readFiles = [] } - public setReadFolders(folder: DocumentReference) { - this._readFolders.push(folder) - } - public clearListOfReadFolders() { - this._readFolders = [] - } async chatIam(chatRequest: SendMessageRequest): Promise { const client = await createQDeveloperStreamingClient() diff --git a/packages/core/src/codewhispererChat/controllers/chat/controller.ts b/packages/core/src/codewhispererChat/controllers/chat/controller.ts index f5108ae4b6c..e6c536c65df 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/controller.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/controller.ts @@ -726,18 +726,9 @@ export class ChatController { try { await ToolUtils.validate(tool) - const chatStream = new ChatStream( - this.messenger, - tabID, - triggerID, - toolUse, - session, - undefined, - false, - { - requiresAcceptance: false, - } - ) + const chatStream = new ChatStream(this.messenger, tabID, triggerID, toolUse, { + requiresAcceptance: false, + }) if (tool.type === ToolType.FsWrite && toolUse.toolUseId) { const backup = await tool.tool.getBackup() session.setFsWriteBackup(toolUse.toolUseId, backup) @@ -1205,7 +1196,6 @@ export class ChatController { private async processPromptMessageAsNewThread(message: PromptMessage) { const session = this.sessionStorage.getSession(message.tabID) session.clearListOfReadFiles() - session.clearListOfReadFolders() session.setShowDiffOnFileWrite(false) this.editorContextExtractor .extractContextForTrigger('ChatMessage') diff --git a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts index e10eeb6a6bb..75e2ceec36b 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts @@ -22,7 +22,6 @@ import { CloseDetailedListMessage, SelectTabMessage, ChatItemHeader, - ToolMessage, } from '../../../view/connector/connector' import { EditorContextCommandType } from '../../../commands/registerCommands' import { ChatResponseStream as qdevChatResponseStream } from '@amzn/amazon-q-developer-streaming-client' @@ -70,8 +69,6 @@ import { FsWriteParams } from '../../../tools/fsWrite' import { AsyncEventProgressMessage } from '../../../../amazonq/commons/connector/connectorMessages' import { localize } from '../../../../shared/utilities/vsCodeUtils' import { getDiffLinesFromChanges } from '../../../../shared/utilities/diffUtils' -import { FsReadParams } from '../../../tools/fsRead' -import { ListDirectoryParams } from '../../../tools/listDirectory' export type StaticTextResponseType = 'quick-action-help' | 'onboarding-help' | 'transform' | 'help' @@ -286,56 +283,20 @@ export class Messenger { session.setShowDiffOnFileWrite(true) changeList = await tool.tool.getDiffChanges() } - if (tool.type === ToolType.FsRead) { - const input = toolUse.input as unknown as FsReadParams - // Check if this file path is already in the readFiles list - const isFileAlreadyRead = session.readFiles.some( - (file) => file.relativeFilePath === input.path - ) - if (!isFileAlreadyRead) { - session.addToReadFiles({ - relativeFilePath: input?.path, - lineRanges: [{ first: -1, second: -1 }], - }) - } - } else if (tool.type === ToolType.ListDirectory) { - const input = toolUse.input as unknown as ListDirectoryParams - session.setReadFolders({ - relativeFilePath: input?.path, - lineRanges: [{ first: -1, second: -1 }], - }) - } const validation = ToolUtils.requiresAcceptance(tool) const chatStream = new ChatStream( this, tabID, triggerID, toolUse, - session, - tool.type === ToolType.FsRead - ? session.messageIdToUpdate - : session.messageIdToUpdateListDirectory, - true, validation, changeList ) await ToolUtils.queueDescription(tool, chatStream) - if (session.messageIdToUpdate === undefined && tool.type === ToolType.FsRead) { - // Store the first messageId in a chain of tool uses - session.setMessageIdToUpdate(toolUse.toolUseId) - } - - if ( - session.messageIdToUpdateListDirectory === undefined && - tool.type === ToolType.ListDirectory - ) { - session.setMessageIdToUpdateListDirectory(toolUse.toolUseId) - } getLogger().debug( `SetToolUseWithError: ${toolUse.name}:${toolUse.toolUseId} with no error` ) session.setToolUseWithError({ toolUse, error: undefined }) - if (!validation.requiresAcceptance) { // Need separate id for read tool and safe bash command execution as 'run-shell-command' id is required to state in cwChatConnector.ts which will impact generic tool execution. if (tool.type === ToolType.ExecuteBash) { @@ -561,26 +522,6 @@ export class Messenger { }) } - public sendInitialToolMessage(tabID: string, triggerID: string, toolUseId: string | undefined) { - this.dispatcher.sendChatMessage( - new ChatMessage( - { - message: '', - messageType: 'answer', - followUps: undefined, - followUpsHeader: undefined, - relatedSuggestions: undefined, - triggerID, - messageID: toolUseId ?? 'toolUse', - userIntent: undefined, - codeBlockLanguage: undefined, - contextList: undefined, - }, - tabID - ) - ) - } - public sendErrorMessage( errorMessage: string | undefined, tabID: string, @@ -598,66 +539,14 @@ export class Messenger { ) } - private sendReadAndListDirToolMessage( - toolUse: ToolUse, - session: ChatSession, - tabID: string, - triggerID: string, - messageIdToUpdate?: string - ) { - const contextList = toolUse.name === ToolType.ListDirectory ? session.readFolders : session.readFiles - const isFileRead = toolUse.name === ToolType.FsRead - const items = isFileRead ? session.readFiles : session.readFolders - const itemCount = items.length - - const title = - itemCount < 1 - ? 'Gathering context' - : isFileRead - ? `${itemCount} file${itemCount > 1 ? 's' : ''} read` - : `${itemCount} ${itemCount === 1 ? 'directory' : 'directories'} listed` - - this.dispatcher.sendToolMessage( - new ToolMessage( - { - message: '', - messageType: 'answer-part', - followUps: undefined, - followUpsHeader: undefined, - relatedSuggestions: undefined, - triggerID, - messageID: messageIdToUpdate ?? toolUse?.toolUseId ?? '', - userIntent: undefined, - codeBlockLanguage: undefined, - contextList, - canBeVoted: false, - buttons: undefined, - fullWidth: false, - padding: false, - codeBlockActions: undefined, - rootFolderTitle: title, - }, - tabID - ) - ) - } - public sendPartialToolLog( message: string, tabID: string, triggerID: string, toolUse: ToolUse | undefined, - session: ChatSession, - messageIdToUpdate: string | undefined, validation: CommandValidation, changeList?: Change[] ) { - // Handle read tool and list directory messages - if (toolUse?.name === ToolType.FsRead || toolUse?.name === ToolType.ListDirectory) { - return this.sendReadAndListDirToolMessage(toolUse, session, tabID, triggerID, messageIdToUpdate) - } - - // Handle file write tool, execute bash tool and bash command output log messages const buttons: ChatItemButton[] = [] let header: ChatItemHeader | undefined = undefined if (toolUse?.name === ToolType.ExecuteBash && message.startsWith('```shell')) { diff --git a/packages/core/src/codewhispererChat/tools/chatStream.ts b/packages/core/src/codewhispererChat/tools/chatStream.ts index 9bb34b21bcd..7e6e4d3ae3a 100644 --- a/packages/core/src/codewhispererChat/tools/chatStream.ts +++ b/packages/core/src/codewhispererChat/tools/chatStream.ts @@ -9,7 +9,6 @@ import { Messenger } from '../controllers/chat/messenger/messenger' import { ToolUse } from '@amzn/codewhisperer-streaming' import { CommandValidation } from './executeBash' import { Change } from 'diff' -import { ChatSession } from '../clients/chat/v0/chat' /** * A writable stream that feeds each chunk/line to the chat UI. @@ -23,38 +22,24 @@ export class ChatStream extends Writable { private readonly tabID: string, private readonly triggerID: string, private readonly toolUse: ToolUse | undefined, - private readonly session: ChatSession, - private readonly messageIdToUpdate: string | undefined, - // emitEvent decides to show the streaming message or read/list directory tool message to the user. - private readonly emitEvent: boolean, private readonly validation: CommandValidation, private readonly changeList?: Change[], private readonly logger = getLogger('chatStream') ) { super() - this.logger.debug( - `ChatStream created for tabID: ${tabID}, triggerID: ${triggerID}, emitEvent to mynahUI: ${emitEvent}` - ) - if (!emitEvent) { - return - } - // If messageIdToUpdate is undefined, we need to first create an empty message with messageId so it can be updated later - messageIdToUpdate - ? this.messenger.sendInitalStream(tabID, triggerID) - : this.messenger.sendInitialToolMessage(tabID, triggerID, toolUse?.toolUseId) + this.logger.debug(`ChatStream created for tabID: ${tabID}, triggerID: ${triggerID}`) + this.messenger.sendInitalStream(tabID, triggerID) } override _write(chunk: Buffer, encoding: BufferEncoding, callback: (error?: Error | null) => void): void { const text = chunk.toString() this.accumulatedLogs += text - this.logger.debug(`ChatStream received chunk: ${text}, emitEvent to mynahUI: ${this.emitEvent}`) + this.logger.debug(`ChatStream received chunk: ${text}`) this.messenger.sendPartialToolLog( this.accumulatedLogs, this.tabID, this.triggerID, this.toolUse, - this.session, - this.messageIdToUpdate, this.validation, this.changeList ) diff --git a/packages/core/src/codewhispererChat/tools/fsRead.ts b/packages/core/src/codewhispererChat/tools/fsRead.ts index ecf90ea6dd3..05519641bd0 100644 --- a/packages/core/src/codewhispererChat/tools/fsRead.ts +++ b/packages/core/src/codewhispererChat/tools/fsRead.ts @@ -7,6 +7,7 @@ import { getLogger } from '../../shared/logger/logger' import fs from '../../shared/fs/fs' import { InvokeOutput, OutputKind, sanitizePath } from './toolShared' import { Writable } from 'stream' +import path from 'path' export interface FsReadParams { path: string @@ -47,7 +48,23 @@ export class FsRead { } public queueDescription(updates: Writable): void { - updates.write('') + const fileName = path.basename(this.fsPath) + const fileUri = vscode.Uri.file(this.fsPath) + updates.write(`Reading file: [${fileName}](${fileUri}), `) + + const [start, end] = this.readRange ?? [] + + if (start && end) { + updates.write(`from line ${start} to ${end}`) + } else if (start) { + if (start > 0) { + updates.write(`from line ${start} to end of file`) + } else { + updates.write(`${start} line from the end of file to end of file`) + } + } else { + updates.write('all lines') + } updates.end() } diff --git a/packages/core/src/codewhispererChat/tools/listDirectory.ts b/packages/core/src/codewhispererChat/tools/listDirectory.ts index a7379169a4d..96ac6972bdc 100644 --- a/packages/core/src/codewhispererChat/tools/listDirectory.ts +++ b/packages/core/src/codewhispererChat/tools/listDirectory.ts @@ -51,12 +51,12 @@ export class ListDirectory { public queueDescription(updates: Writable): void { const fileName = path.basename(this.fsPath) if (this.maxDepth === undefined) { - updates.write(`Analyzing directories recursively: ${fileName}`) + updates.write(`Listing directory recursively: ${fileName}`) } else if (this.maxDepth === 0) { - updates.write(`Analyzing directory: ${fileName}`) + updates.write(`Listing directory: ${fileName}`) } else { const level = this.maxDepth > 1 ? 'levels' : 'level' - updates.write(`Analyzing directory: ${fileName} limited to ${this.maxDepth} subfolder ${level}`) + updates.write(`Listing directory: ${fileName} limited to ${this.maxDepth} subfolder ${level}`) } updates.end() } diff --git a/packages/core/src/codewhispererChat/view/connector/connector.ts b/packages/core/src/codewhispererChat/view/connector/connector.ts index 6e2aa92fe63..6f04d09cd46 100644 --- a/packages/core/src/codewhispererChat/view/connector/connector.ts +++ b/packages/core/src/codewhispererChat/view/connector/connector.ts @@ -404,10 +404,6 @@ export class ChatMessage extends UiMessage { } } -export class ToolMessage extends ChatMessage { - override type = 'toolMessage' -} - export interface FollowUp { readonly type: string readonly pillText: string @@ -462,10 +458,6 @@ export class AppToWebViewMessageDispatcher { this.appsToWebViewMessagePublisher.publish(message) } - public sendToolMessage(message: ToolMessage) { - this.appsToWebViewMessagePublisher.publish(message) - } - public sendEditorContextCommandMessage(message: EditorContextCommandMessage) { this.appsToWebViewMessagePublisher.publish(message) } From 3e6b0abbed692a6d615aac257c39378d4c732532 Mon Sep 17 00:00:00 2001 From: laileni Date: Thu, 10 Apr 2025 14:43:10 -0700 Subject: [PATCH 2/4] Reading files UX improvement This reverts commit 1fc1fd7fdfaf2daed27cbec9480a60deae2a902a. --- .../webview/ui/apps/cwChatConnector.ts | 32 +++++ .../core/src/amazonq/webview/ui/connector.ts | 2 +- packages/core/src/amazonq/webview/ui/main.ts | 67 ++++++---- .../codewhispererChat/clients/chat/v0/chat.ts | 36 +++++- .../controllers/chat/controller.ts | 17 ++- .../controllers/chat/messenger/messenger.ts | 120 ++++++++++++++++++ .../src/codewhispererChat/tools/chatStream.ts | 26 +++- .../src/codewhispererChat/tools/fsRead.ts | 19 +-- .../codewhispererChat/tools/listDirectory.ts | 6 +- .../view/connector/connector.ts | 8 ++ 10 files changed, 274 insertions(+), 59 deletions(-) diff --git a/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts b/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts index b52d913e7a2..ab175c1da6f 100644 --- a/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts @@ -179,6 +179,33 @@ export class Connector extends BaseConnector { } } + private processToolMessage = async (messageData: any): Promise => { + if (this.onChatAnswerUpdated === undefined) { + return + } + const answer: CWCChatItem = { + type: messageData.messageType, + messageId: messageData.messageID ?? messageData.triggerID, + body: messageData.message, + followUp: messageData.followUps, + canBeVoted: messageData.canBeVoted ?? false, + codeReference: messageData.codeReference, + userIntent: messageData.contextList, + codeBlockLanguage: messageData.codeBlockLanguage, + contextList: messageData.contextList, + title: messageData.title, + buttons: messageData.buttons, + fileList: messageData.fileList, + header: messageData.header ?? undefined, + padding: messageData.padding ?? undefined, + fullWidth: messageData.fullWidth ?? undefined, + codeBlockActions: messageData.codeBlockActions ?? undefined, + rootFolderTitle: messageData.rootFolderTitle, + } + this.onChatAnswerUpdated(messageData.tabID, answer) + return + } + private storeChatItem(tabId: string, messageId: string, item: ChatItem): void { if (!this.chatItems.has(tabId)) { this.chatItems.set(tabId, new Map()) @@ -238,6 +265,11 @@ export class Connector extends BaseConnector { return } + if (messageData.type === 'toolMessage') { + await this.processToolMessage(messageData) + return + } + if (messageData.type === 'editorContextCommandMessage') { await this.processEditorContextCommandMessage(messageData) return diff --git a/packages/core/src/amazonq/webview/ui/connector.ts b/packages/core/src/amazonq/webview/ui/connector.ts index 9ed46f8f58d..04a740624f1 100644 --- a/packages/core/src/amazonq/webview/ui/connector.ts +++ b/packages/core/src/amazonq/webview/ui/connector.ts @@ -78,7 +78,7 @@ export interface ConnectorProps { sendMessageToExtension: (message: ExtensionMessage) => void onMessageReceived?: (tabID: string, messageData: any, needToShowAPIDocsTab: boolean) => void onRunTestMessageReceived?: (tabID: string, showRunTestMessage: boolean) => void - onChatAnswerUpdated?: (tabID: string, message: ChatItem) => void + onChatAnswerUpdated?: (tabID: string, message: CWCChatItem) => void onChatAnswerReceived?: (tabID: string, message: ChatItem, messageData: any) => void onWelcomeFollowUpClicked: (tabID: string, welcomeFollowUpType: WelcomeFollowupType) => void onAsyncEventProgress: (tabID: string, inProgress: boolean, message: string | undefined) => void diff --git a/packages/core/src/amazonq/webview/ui/main.ts b/packages/core/src/amazonq/webview/ui/main.ts index 580229d3ee2..3204ebe65b7 100644 --- a/packages/core/src/amazonq/webview/ui/main.ts +++ b/packages/core/src/amazonq/webview/ui/main.ts @@ -100,6 +100,41 @@ export const createMynahUI = ( welcomeCount += 1 } + /** + * Creates a file list header from context list + * @param contextList List of file contexts + * @param rootFolderTitle Title for the root folder + * @returns Header object with file list + */ + const createFileListHeader = (contextList: any[], rootFolderTitle?: string) => { + return { + fileList: { + fileTreeTitle: '', + filePaths: contextList.map((file) => file.relativeFilePath), + rootFolderTitle: rootFolderTitle, + flatList: true, + collapsed: true, + hideFileCount: true, + details: Object.fromEntries( + contextList.map((file) => [ + file.relativeFilePath, + { + label: file.lineRanges + .map((range: { first: number; second: number }) => + range.first === -1 || range.second === -1 + ? '' + : `line ${range.first} - ${range.second}` + ) + .join(', '), + description: file.relativeFilePath, + clickable: true, + }, + ]) + ), + }, + } + } + // Adding the first tab as CWC tab tabsStorage.addTab({ id: 'tab-1', @@ -346,8 +381,11 @@ export const createMynahUI = ( sendMessageToExtension: (message) => { ideApi.postMessage(message) }, - onChatAnswerUpdated: (tabID: string, item: ChatItem) => { + onChatAnswerUpdated: (tabID: string, item: CWCChatItem) => { if (item.messageId !== undefined) { + if (item.contextList !== undefined && item.contextList.length > 0) { + item.header = createFileListHeader(item.contextList, item.rootFolderTitle) + } mynahUI.updateChatAnswerWithMessageId(tabID, item.messageId, { ...(item.body !== undefined ? { body: item.body } : {}), ...(item.buttons !== undefined ? { buttons: item.buttons } : {}), @@ -409,32 +447,7 @@ export const createMynahUI = ( } if (item.contextList !== undefined && item.contextList.length > 0) { - item.header = { - fileList: { - fileTreeTitle: '', - filePaths: item.contextList.map((file) => file.relativeFilePath), - rootFolderTitle: item.rootFolderTitle, - flatList: true, - collapsed: true, - hideFileCount: true, - details: Object.fromEntries( - item.contextList.map((file) => [ - file.relativeFilePath, - { - label: file.lineRanges - .map((range) => - range.first === -1 || range.second === -1 - ? '' - : `line ${range.first} - ${range.second}` - ) - .join(', '), - description: file.relativeFilePath, - clickable: true, - }, - ]) - ), - }, - } + item.header = createFileListHeader(item.contextList, item.rootFolderTitle) } if ( diff --git a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts index 53551b254f5..10409be4ada 100644 --- a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts +++ b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts @@ -14,7 +14,7 @@ import { ToolkitError } from '../../../../shared/errors' import { createCodeWhispererChatStreamingClient } from '../../../../shared/clients/codewhispererChatClient' import { createQDeveloperStreamingClient } from '../../../../shared/clients/qDeveloperChatClient' import { UserWrittenCodeTracker } from '../../../../codewhisperer/tracker/userWrittenCodeTracker' -import { PromptMessage } from '../../../controllers/chat/model' +import { DocumentReference, PromptMessage } from '../../../controllers/chat/model' import { FsWriteBackup } from '../../../../codewhispererChat/tools/fsWrite' export type ToolUseWithError = { @@ -30,8 +30,10 @@ export class ChatSession { * _readFiles = list of files read from the project to gather context before generating response. * _showDiffOnFileWrite = Controls whether to show diff view (true) or file context view (false) to the user * _context = Additional context to be passed to the LLM for generating the response + * _messageIdToUpdate = messageId of a chat message to be updated, used for reducing consecutive tool messages */ - private _readFiles: string[] = [] + private _readFiles: DocumentReference[] = [] + private _readFolders: DocumentReference[] = [] private _toolUseWithError: ToolUseWithError | undefined private _showDiffOnFileWrite: boolean = false private _context: PromptMessage['context'] @@ -41,6 +43,8 @@ export class ChatSession { * True if messages from local history have been sent to session. */ localHistoryHydrated: boolean = false + private _messageIdToUpdate: string | undefined + private _messageIdToUpdateListDirectory: string | undefined contexts: Map = new Map() // TODO: doesn't handle the edge case when two files share the same relativePath string but from different root @@ -49,6 +53,21 @@ export class ChatSession { public get sessionIdentifier(): string | undefined { return this.sessionId } + public get messageIdToUpdate(): string | undefined { + return this._messageIdToUpdate + } + + public setMessageIdToUpdate(messageId: string | undefined) { + this._messageIdToUpdate = messageId + } + + public get messageIdToUpdateListDirectory(): string | undefined { + return this._messageIdToUpdateListDirectory + } + + public setMessageIdToUpdateListDirectory(messageId: string | undefined) { + this._messageIdToUpdateListDirectory = messageId + } public get pairProgrammingModeOn(): boolean { return this._pairProgrammingModeOn @@ -95,21 +114,30 @@ export class ChatSession { public setSessionID(id?: string) { this.sessionId = id } - public get readFiles(): string[] { + public get readFiles(): DocumentReference[] { return this._readFiles } + public get readFolders(): DocumentReference[] { + return this._readFolders + } public get showDiffOnFileWrite(): boolean { return this._showDiffOnFileWrite } public setShowDiffOnFileWrite(value: boolean) { this._showDiffOnFileWrite = value } - public addToReadFiles(filePath: string) { + public addToReadFiles(filePath: DocumentReference) { this._readFiles.push(filePath) } public clearListOfReadFiles() { this._readFiles = [] } + public setReadFolders(folder: DocumentReference) { + this._readFolders.push(folder) + } + public clearListOfReadFolders() { + this._readFolders = [] + } async chatIam(chatRequest: SendMessageRequest): Promise { const client = await createQDeveloperStreamingClient() diff --git a/packages/core/src/codewhispererChat/controllers/chat/controller.ts b/packages/core/src/codewhispererChat/controllers/chat/controller.ts index e6c536c65df..c8ece4f6dc0 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/controller.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/controller.ts @@ -726,9 +726,19 @@ export class ChatController { try { await ToolUtils.validate(tool) - const chatStream = new ChatStream(this.messenger, tabID, triggerID, toolUse, { - requiresAcceptance: false, - }) + const chatStream = new ChatStream( + this.messenger, + tabID, + triggerID, + toolUse, + session, + undefined, + false, + { + requiresAcceptance: false, + }, + false + ) if (tool.type === ToolType.FsWrite && toolUse.toolUseId) { const backup = await tool.tool.getBackup() session.setFsWriteBackup(toolUse.toolUseId, backup) @@ -1196,6 +1206,7 @@ export class ChatController { private async processPromptMessageAsNewThread(message: PromptMessage) { const session = this.sessionStorage.getSession(message.tabID) session.clearListOfReadFiles() + session.clearListOfReadFolders() session.setShowDiffOnFileWrite(false) this.editorContextExtractor .extractContextForTrigger('ChatMessage') diff --git a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts index 75e2ceec36b..b6bdd6e353e 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts @@ -22,6 +22,7 @@ import { CloseDetailedListMessage, SelectTabMessage, ChatItemHeader, + ToolMessage, } from '../../../view/connector/connector' import { EditorContextCommandType } from '../../../commands/registerCommands' import { ChatResponseStream as qdevChatResponseStream } from '@amzn/amazon-q-developer-streaming-client' @@ -69,6 +70,8 @@ import { FsWriteParams } from '../../../tools/fsWrite' import { AsyncEventProgressMessage } from '../../../../amazonq/commons/connector/connectorMessages' import { localize } from '../../../../shared/utilities/vsCodeUtils' import { getDiffLinesFromChanges } from '../../../../shared/utilities/diffUtils' +import { FsReadParams } from '../../../tools/fsRead' +import { ListDirectoryParams } from '../../../tools/listDirectory' export type StaticTextResponseType = 'quick-action-help' | 'onboarding-help' | 'transform' | 'help' @@ -283,20 +286,65 @@ export class Messenger { session.setShowDiffOnFileWrite(true) changeList = await tool.tool.getDiffChanges() } + if (tool.type === ToolType.FsRead) { + const input = toolUse.input as unknown as FsReadParams + // Check if this file path is already in the readFiles list + const isFileAlreadyRead = session.readFiles.some( + (file) => file.relativeFilePath === input.path + ) + if (!isFileAlreadyRead) { + session.addToReadFiles({ + relativeFilePath: input?.path, + lineRanges: [{ first: -1, second: -1 }], + }) + } + } else if (tool.type === ToolType.ListDirectory) { + const input = toolUse.input as unknown as ListDirectoryParams + session.setReadFolders({ + relativeFilePath: input?.path, + lineRanges: [{ first: -1, second: -1 }], + }) + } + let messageIdToUpdate: string | undefined = undefined + const isReadOrList: boolean = [ToolType.FsRead, ToolType.ListDirectory].includes( + tool.type + ) + if (isReadOrList) { + messageIdToUpdate = + tool.type === ToolType.FsRead + ? session.messageIdToUpdate + : session.messageIdToUpdateListDirectory + } const validation = ToolUtils.requiresAcceptance(tool) const chatStream = new ChatStream( this, tabID, triggerID, toolUse, + session, + messageIdToUpdate, + true, validation, + isReadOrList, changeList ) await ToolUtils.queueDescription(tool, chatStream) + if (session.messageIdToUpdate === undefined && tool.type === ToolType.FsRead) { + // Store the first messageId in a chain of tool uses + session.setMessageIdToUpdate(toolUse.toolUseId) + } + + if ( + session.messageIdToUpdateListDirectory === undefined && + tool.type === ToolType.ListDirectory + ) { + session.setMessageIdToUpdateListDirectory(toolUse.toolUseId) + } getLogger().debug( `SetToolUseWithError: ${toolUse.name}:${toolUse.toolUseId} with no error` ) session.setToolUseWithError({ toolUse, error: undefined }) + if (!validation.requiresAcceptance) { // Need separate id for read tool and safe bash command execution as 'run-shell-command' id is required to state in cwChatConnector.ts which will impact generic tool execution. if (tool.type === ToolType.ExecuteBash) { @@ -522,6 +570,26 @@ export class Messenger { }) } + public sendInitialToolMessage(tabID: string, triggerID: string, toolUseId: string | undefined) { + this.dispatcher.sendChatMessage( + new ChatMessage( + { + message: '', + messageType: 'answer', + followUps: undefined, + followUpsHeader: undefined, + relatedSuggestions: undefined, + triggerID, + messageID: toolUseId ?? 'toolUse', + userIntent: undefined, + codeBlockLanguage: undefined, + contextList: undefined, + }, + tabID + ) + ) + } + public sendErrorMessage( errorMessage: string | undefined, tabID: string, @@ -539,14 +607,66 @@ export class Messenger { ) } + private sendReadAndListDirToolMessage( + toolUse: ToolUse, + session: ChatSession, + tabID: string, + triggerID: string, + messageIdToUpdate?: string + ) { + const contextList = toolUse.name === ToolType.ListDirectory ? session.readFolders : session.readFiles + const isFileRead = toolUse.name === ToolType.FsRead + const items = isFileRead ? session.readFiles : session.readFolders + const itemCount = items.length + + const title = + itemCount < 1 + ? 'Gathering context' + : isFileRead + ? `${itemCount} file${itemCount > 1 ? 's' : ''} read` + : `${itemCount} ${itemCount === 1 ? 'directory' : 'directories'} listed` + + this.dispatcher.sendToolMessage( + new ToolMessage( + { + message: '', + messageType: 'answer-part', + followUps: undefined, + followUpsHeader: undefined, + relatedSuggestions: undefined, + triggerID, + messageID: messageIdToUpdate ?? toolUse?.toolUseId ?? '', + userIntent: undefined, + codeBlockLanguage: undefined, + contextList, + canBeVoted: false, + buttons: undefined, + fullWidth: false, + padding: false, + codeBlockActions: undefined, + rootFolderTitle: title, + }, + tabID + ) + ) + } + public sendPartialToolLog( message: string, tabID: string, triggerID: string, toolUse: ToolUse | undefined, + session: ChatSession, + messageIdToUpdate: string | undefined, validation: CommandValidation, changeList?: Change[] ) { + // Handle read tool and list directory messages + if (toolUse?.name === ToolType.FsRead || toolUse?.name === ToolType.ListDirectory) { + return this.sendReadAndListDirToolMessage(toolUse, session, tabID, triggerID, messageIdToUpdate) + } + + // Handle file write tool, execute bash tool and bash command output log messages const buttons: ChatItemButton[] = [] let header: ChatItemHeader | undefined = undefined if (toolUse?.name === ToolType.ExecuteBash && message.startsWith('```shell')) { diff --git a/packages/core/src/codewhispererChat/tools/chatStream.ts b/packages/core/src/codewhispererChat/tools/chatStream.ts index 7e6e4d3ae3a..909963d1527 100644 --- a/packages/core/src/codewhispererChat/tools/chatStream.ts +++ b/packages/core/src/codewhispererChat/tools/chatStream.ts @@ -9,6 +9,7 @@ import { Messenger } from '../controllers/chat/messenger/messenger' import { ToolUse } from '@amzn/codewhisperer-streaming' import { CommandValidation } from './executeBash' import { Change } from 'diff' +import { ChatSession } from '../clients/chat/v0/chat' /** * A writable stream that feeds each chunk/line to the chat UI. @@ -22,24 +23,43 @@ export class ChatStream extends Writable { private readonly tabID: string, private readonly triggerID: string, private readonly toolUse: ToolUse | undefined, + private readonly session: ChatSession, + private readonly messageIdToUpdate: string | undefined, + // emitEvent decides to show the streaming message or read/list directory tool message to the user. + private readonly emitEvent: boolean, private readonly validation: CommandValidation, + private readonly isReadorList: boolean, private readonly changeList?: Change[], private readonly logger = getLogger('chatStream') ) { super() - this.logger.debug(`ChatStream created for tabID: ${tabID}, triggerID: ${triggerID}`) - this.messenger.sendInitalStream(tabID, triggerID) + this.logger.debug( + `ChatStream created for tabID: ${tabID}, triggerID: ${triggerID}, readFiles: ${session.readFiles}, emitEvent to mynahUI: ${emitEvent}` + ) + if (!emitEvent) { + return + } + // For FsRead and ListDirectory tools If messageIdToUpdate is undefined, we need to first create an empty message with messageId so it can be updated later + if (isReadorList && !messageIdToUpdate) { + this.messenger.sendInitialToolMessage(tabID, triggerID, toolUse?.toolUseId) + } else { + this.messenger.sendInitalStream(tabID, triggerID) + } } override _write(chunk: Buffer, encoding: BufferEncoding, callback: (error?: Error | null) => void): void { const text = chunk.toString() this.accumulatedLogs += text - this.logger.debug(`ChatStream received chunk: ${text}`) + this.logger.debug( + `ChatStream received chunk: ${text}, emitEvent to mynahUI: ${this.emitEvent}, isReadorList tool: ${this.isReadorList}` + ) this.messenger.sendPartialToolLog( this.accumulatedLogs, this.tabID, this.triggerID, this.toolUse, + this.session, + this.messageIdToUpdate, this.validation, this.changeList ) diff --git a/packages/core/src/codewhispererChat/tools/fsRead.ts b/packages/core/src/codewhispererChat/tools/fsRead.ts index 05519641bd0..ecf90ea6dd3 100644 --- a/packages/core/src/codewhispererChat/tools/fsRead.ts +++ b/packages/core/src/codewhispererChat/tools/fsRead.ts @@ -7,7 +7,6 @@ import { getLogger } from '../../shared/logger/logger' import fs from '../../shared/fs/fs' import { InvokeOutput, OutputKind, sanitizePath } from './toolShared' import { Writable } from 'stream' -import path from 'path' export interface FsReadParams { path: string @@ -48,23 +47,7 @@ export class FsRead { } public queueDescription(updates: Writable): void { - const fileName = path.basename(this.fsPath) - const fileUri = vscode.Uri.file(this.fsPath) - updates.write(`Reading file: [${fileName}](${fileUri}), `) - - const [start, end] = this.readRange ?? [] - - if (start && end) { - updates.write(`from line ${start} to ${end}`) - } else if (start) { - if (start > 0) { - updates.write(`from line ${start} to end of file`) - } else { - updates.write(`${start} line from the end of file to end of file`) - } - } else { - updates.write('all lines') - } + updates.write('') updates.end() } diff --git a/packages/core/src/codewhispererChat/tools/listDirectory.ts b/packages/core/src/codewhispererChat/tools/listDirectory.ts index 96ac6972bdc..a7379169a4d 100644 --- a/packages/core/src/codewhispererChat/tools/listDirectory.ts +++ b/packages/core/src/codewhispererChat/tools/listDirectory.ts @@ -51,12 +51,12 @@ export class ListDirectory { public queueDescription(updates: Writable): void { const fileName = path.basename(this.fsPath) if (this.maxDepth === undefined) { - updates.write(`Listing directory recursively: ${fileName}`) + updates.write(`Analyzing directories recursively: ${fileName}`) } else if (this.maxDepth === 0) { - updates.write(`Listing directory: ${fileName}`) + updates.write(`Analyzing directory: ${fileName}`) } else { const level = this.maxDepth > 1 ? 'levels' : 'level' - updates.write(`Listing directory: ${fileName} limited to ${this.maxDepth} subfolder ${level}`) + updates.write(`Analyzing directory: ${fileName} limited to ${this.maxDepth} subfolder ${level}`) } updates.end() } diff --git a/packages/core/src/codewhispererChat/view/connector/connector.ts b/packages/core/src/codewhispererChat/view/connector/connector.ts index 6f04d09cd46..6e2aa92fe63 100644 --- a/packages/core/src/codewhispererChat/view/connector/connector.ts +++ b/packages/core/src/codewhispererChat/view/connector/connector.ts @@ -404,6 +404,10 @@ export class ChatMessage extends UiMessage { } } +export class ToolMessage extends ChatMessage { + override type = 'toolMessage' +} + export interface FollowUp { readonly type: string readonly pillText: string @@ -458,6 +462,10 @@ export class AppToWebViewMessageDispatcher { this.appsToWebViewMessagePublisher.publish(message) } + public sendToolMessage(message: ToolMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + public sendEditorContextCommandMessage(message: EditorContextCommandMessage) { this.appsToWebViewMessagePublisher.publish(message) } From 2028754f3d933909f86bb65f18f06dab526d2708 Mon Sep 17 00:00:00 2001 From: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> Date: Wed, 9 Apr 2025 18:07:03 -0700 Subject: [PATCH 3/4] fix(amazonq): Grouping read tool messages under contextList (#6975) - We show read tool message for each file read which is occupying the entire chat with these messages which is not super important. - Group all the read tool messaged under ContextList. ![image](https://github.com/user-attachments/assets/194e4ca1-7645-4419-8b9b-b5ac9a58279f) --- - 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. --- .../webview/ui/apps/cwChatConnector.ts | 32 +++++ .../core/src/amazonq/webview/ui/connector.ts | 2 +- packages/core/src/amazonq/webview/ui/main.ts | 67 ++++++---- .../codewhispererChat/clients/chat/v0/chat.ts | 36 +++++- .../controllers/chat/controller.ts | 1 + .../controllers/chat/messenger/messenger.ts | 115 +++++++++++++++--- .../src/codewhispererChat/tools/chatStream.ts | 7 ++ .../src/codewhispererChat/tools/fsRead.ts | 19 +-- .../codewhispererChat/tools/listDirectory.ts | 6 +- .../view/connector/connector.ts | 8 ++ 10 files changed, 226 insertions(+), 67 deletions(-) diff --git a/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts b/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts index b52d913e7a2..ab175c1da6f 100644 --- a/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts @@ -179,6 +179,33 @@ export class Connector extends BaseConnector { } } + private processToolMessage = async (messageData: any): Promise => { + if (this.onChatAnswerUpdated === undefined) { + return + } + const answer: CWCChatItem = { + type: messageData.messageType, + messageId: messageData.messageID ?? messageData.triggerID, + body: messageData.message, + followUp: messageData.followUps, + canBeVoted: messageData.canBeVoted ?? false, + codeReference: messageData.codeReference, + userIntent: messageData.contextList, + codeBlockLanguage: messageData.codeBlockLanguage, + contextList: messageData.contextList, + title: messageData.title, + buttons: messageData.buttons, + fileList: messageData.fileList, + header: messageData.header ?? undefined, + padding: messageData.padding ?? undefined, + fullWidth: messageData.fullWidth ?? undefined, + codeBlockActions: messageData.codeBlockActions ?? undefined, + rootFolderTitle: messageData.rootFolderTitle, + } + this.onChatAnswerUpdated(messageData.tabID, answer) + return + } + private storeChatItem(tabId: string, messageId: string, item: ChatItem): void { if (!this.chatItems.has(tabId)) { this.chatItems.set(tabId, new Map()) @@ -238,6 +265,11 @@ export class Connector extends BaseConnector { return } + if (messageData.type === 'toolMessage') { + await this.processToolMessage(messageData) + return + } + if (messageData.type === 'editorContextCommandMessage') { await this.processEditorContextCommandMessage(messageData) return diff --git a/packages/core/src/amazonq/webview/ui/connector.ts b/packages/core/src/amazonq/webview/ui/connector.ts index 9ed46f8f58d..04a740624f1 100644 --- a/packages/core/src/amazonq/webview/ui/connector.ts +++ b/packages/core/src/amazonq/webview/ui/connector.ts @@ -78,7 +78,7 @@ export interface ConnectorProps { sendMessageToExtension: (message: ExtensionMessage) => void onMessageReceived?: (tabID: string, messageData: any, needToShowAPIDocsTab: boolean) => void onRunTestMessageReceived?: (tabID: string, showRunTestMessage: boolean) => void - onChatAnswerUpdated?: (tabID: string, message: ChatItem) => void + onChatAnswerUpdated?: (tabID: string, message: CWCChatItem) => void onChatAnswerReceived?: (tabID: string, message: ChatItem, messageData: any) => void onWelcomeFollowUpClicked: (tabID: string, welcomeFollowUpType: WelcomeFollowupType) => void onAsyncEventProgress: (tabID: string, inProgress: boolean, message: string | undefined) => void diff --git a/packages/core/src/amazonq/webview/ui/main.ts b/packages/core/src/amazonq/webview/ui/main.ts index 580229d3ee2..3204ebe65b7 100644 --- a/packages/core/src/amazonq/webview/ui/main.ts +++ b/packages/core/src/amazonq/webview/ui/main.ts @@ -100,6 +100,41 @@ export const createMynahUI = ( welcomeCount += 1 } + /** + * Creates a file list header from context list + * @param contextList List of file contexts + * @param rootFolderTitle Title for the root folder + * @returns Header object with file list + */ + const createFileListHeader = (contextList: any[], rootFolderTitle?: string) => { + return { + fileList: { + fileTreeTitle: '', + filePaths: contextList.map((file) => file.relativeFilePath), + rootFolderTitle: rootFolderTitle, + flatList: true, + collapsed: true, + hideFileCount: true, + details: Object.fromEntries( + contextList.map((file) => [ + file.relativeFilePath, + { + label: file.lineRanges + .map((range: { first: number; second: number }) => + range.first === -1 || range.second === -1 + ? '' + : `line ${range.first} - ${range.second}` + ) + .join(', '), + description: file.relativeFilePath, + clickable: true, + }, + ]) + ), + }, + } + } + // Adding the first tab as CWC tab tabsStorage.addTab({ id: 'tab-1', @@ -346,8 +381,11 @@ export const createMynahUI = ( sendMessageToExtension: (message) => { ideApi.postMessage(message) }, - onChatAnswerUpdated: (tabID: string, item: ChatItem) => { + onChatAnswerUpdated: (tabID: string, item: CWCChatItem) => { if (item.messageId !== undefined) { + if (item.contextList !== undefined && item.contextList.length > 0) { + item.header = createFileListHeader(item.contextList, item.rootFolderTitle) + } mynahUI.updateChatAnswerWithMessageId(tabID, item.messageId, { ...(item.body !== undefined ? { body: item.body } : {}), ...(item.buttons !== undefined ? { buttons: item.buttons } : {}), @@ -409,32 +447,7 @@ export const createMynahUI = ( } if (item.contextList !== undefined && item.contextList.length > 0) { - item.header = { - fileList: { - fileTreeTitle: '', - filePaths: item.contextList.map((file) => file.relativeFilePath), - rootFolderTitle: item.rootFolderTitle, - flatList: true, - collapsed: true, - hideFileCount: true, - details: Object.fromEntries( - item.contextList.map((file) => [ - file.relativeFilePath, - { - label: file.lineRanges - .map((range) => - range.first === -1 || range.second === -1 - ? '' - : `line ${range.first} - ${range.second}` - ) - .join(', '), - description: file.relativeFilePath, - clickable: true, - }, - ]) - ), - }, - } + item.header = createFileListHeader(item.contextList, item.rootFolderTitle) } if ( diff --git a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts index 53551b254f5..10409be4ada 100644 --- a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts +++ b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts @@ -14,7 +14,7 @@ import { ToolkitError } from '../../../../shared/errors' import { createCodeWhispererChatStreamingClient } from '../../../../shared/clients/codewhispererChatClient' import { createQDeveloperStreamingClient } from '../../../../shared/clients/qDeveloperChatClient' import { UserWrittenCodeTracker } from '../../../../codewhisperer/tracker/userWrittenCodeTracker' -import { PromptMessage } from '../../../controllers/chat/model' +import { DocumentReference, PromptMessage } from '../../../controllers/chat/model' import { FsWriteBackup } from '../../../../codewhispererChat/tools/fsWrite' export type ToolUseWithError = { @@ -30,8 +30,10 @@ export class ChatSession { * _readFiles = list of files read from the project to gather context before generating response. * _showDiffOnFileWrite = Controls whether to show diff view (true) or file context view (false) to the user * _context = Additional context to be passed to the LLM for generating the response + * _messageIdToUpdate = messageId of a chat message to be updated, used for reducing consecutive tool messages */ - private _readFiles: string[] = [] + private _readFiles: DocumentReference[] = [] + private _readFolders: DocumentReference[] = [] private _toolUseWithError: ToolUseWithError | undefined private _showDiffOnFileWrite: boolean = false private _context: PromptMessage['context'] @@ -41,6 +43,8 @@ export class ChatSession { * True if messages from local history have been sent to session. */ localHistoryHydrated: boolean = false + private _messageIdToUpdate: string | undefined + private _messageIdToUpdateListDirectory: string | undefined contexts: Map = new Map() // TODO: doesn't handle the edge case when two files share the same relativePath string but from different root @@ -49,6 +53,21 @@ export class ChatSession { public get sessionIdentifier(): string | undefined { return this.sessionId } + public get messageIdToUpdate(): string | undefined { + return this._messageIdToUpdate + } + + public setMessageIdToUpdate(messageId: string | undefined) { + this._messageIdToUpdate = messageId + } + + public get messageIdToUpdateListDirectory(): string | undefined { + return this._messageIdToUpdateListDirectory + } + + public setMessageIdToUpdateListDirectory(messageId: string | undefined) { + this._messageIdToUpdateListDirectory = messageId + } public get pairProgrammingModeOn(): boolean { return this._pairProgrammingModeOn @@ -95,21 +114,30 @@ export class ChatSession { public setSessionID(id?: string) { this.sessionId = id } - public get readFiles(): string[] { + public get readFiles(): DocumentReference[] { return this._readFiles } + public get readFolders(): DocumentReference[] { + return this._readFolders + } public get showDiffOnFileWrite(): boolean { return this._showDiffOnFileWrite } public setShowDiffOnFileWrite(value: boolean) { this._showDiffOnFileWrite = value } - public addToReadFiles(filePath: string) { + public addToReadFiles(filePath: DocumentReference) { this._readFiles.push(filePath) } public clearListOfReadFiles() { this._readFiles = [] } + public setReadFolders(folder: DocumentReference) { + this._readFolders.push(folder) + } + public clearListOfReadFolders() { + this._readFolders = [] + } async chatIam(chatRequest: SendMessageRequest): Promise { const client = await createQDeveloperStreamingClient() diff --git a/packages/core/src/codewhispererChat/controllers/chat/controller.ts b/packages/core/src/codewhispererChat/controllers/chat/controller.ts index bc176bae7db..2fa565caecd 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/controller.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/controller.ts @@ -1215,6 +1215,7 @@ export class ChatController { private async processPromptMessageAsNewThread(message: PromptMessage) { const session = this.sessionStorage.getSession(message.tabID) session.clearListOfReadFiles() + session.clearListOfReadFolders() session.setShowDiffOnFileWrite(false) this.editorContextExtractor .extractContextForTrigger('ChatMessage') diff --git a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts index e36cac0b873..27cf3c99ea4 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts @@ -22,6 +22,7 @@ import { CloseDetailedListMessage, SelectTabMessage, ChatItemHeader, + ToolMessage, } from '../../../view/connector/connector' import { EditorContextCommandType } from '../../../commands/registerCommands' import { ChatResponseStream as qdevChatResponseStream } from '@amzn/amazon-q-developer-streaming-client' @@ -69,6 +70,8 @@ import { FsWriteParams } from '../../../tools/fsWrite' import { AsyncEventProgressMessage } from '../../../../amazonq/commons/connector/connectorMessages' import { localize } from '../../../../shared/utilities/vsCodeUtils' import { getDiffLinesFromChanges } from '../../../../shared/utilities/diffUtils' +import { FsReadParams } from '../../../tools/fsRead' +import { ListDirectoryParams } from '../../../tools/listDirectory' export type StaticTextResponseType = 'quick-action-help' | 'onboarding-help' | 'transform' | 'help' @@ -284,11 +287,16 @@ export class Messenger { const tool = ToolUtils.tryFromToolUse(toolUse) if ('type' in tool) { let changeList: Change[] | undefined = undefined + let messageIdToUpdate: string | undefined = undefined + const isReadOrList: boolean = [ToolType.FsRead, ToolType.ListDirectory].includes( + tool.type + ) if (tool.type === ToolType.FsWrite) { session.setShowDiffOnFileWrite(true) changeList = await tool.tool.getDiffChanges() } if (tool.type === ToolType.FsRead) { + messageIdToUpdate = session.messageIdToUpdate const input = toolUse.input as unknown as FsReadParams // Check if this file path is already in the readFiles list const isFileAlreadyRead = session.readFiles.some( @@ -301,21 +309,18 @@ export class Messenger { }) } } else if (tool.type === ToolType.ListDirectory) { + messageIdToUpdate = session.messageIdToUpdateListDirectory const input = toolUse.input as unknown as ListDirectoryParams - session.setReadFolders({ - relativeFilePath: input?.path, - lineRanges: [{ first: -1, second: -1 }], - }) - } - let messageIdToUpdate: string | undefined = undefined - const isReadOrList: boolean = [ToolType.FsRead, ToolType.ListDirectory].includes( - tool.type - ) - if (isReadOrList) { - messageIdToUpdate = - tool.type === ToolType.FsRead - ? session.messageIdToUpdate - : session.messageIdToUpdateListDirectory + // Check if this folder is already in the readFolders list + const isFolderAlreadyRead = session.readFolders.some( + (folder) => folder.relativeFilePath === input.path + ) + if (!isFolderAlreadyRead) { + session.setReadFolders({ + relativeFilePath: input?.path, + lineRanges: [{ first: -1, second: -1 }], + }) + } } const validation = ToolUtils.requiresAcceptance(tool) const chatStream = new ChatStream( @@ -331,6 +336,16 @@ export class Messenger { changeList ) await ToolUtils.queueDescription(tool, chatStream) + if (session.messageIdToUpdate === undefined && tool.type === ToolType.FsRead) { + // Store the first messageId in a chain of tool uses + session.setMessageIdToUpdate(toolUse.toolUseId) + } + if ( + session.messageIdToUpdateListDirectory === undefined && + tool.type === ToolType.ListDirectory + ) { + session.setMessageIdToUpdateListDirectory(toolUse.toolUseId) + } getLogger().debug( `SetToolUseWithError: ${toolUse.name}:${toolUse.toolUseId} with no error` ) @@ -574,6 +589,26 @@ export class Messenger { }) } + public sendInitialToolMessage(tabID: string, triggerID: string, toolUseId: string | undefined) { + this.dispatcher.sendChatMessage( + new ChatMessage( + { + message: '', + messageType: 'answer', + followUps: undefined, + followUpsHeader: undefined, + relatedSuggestions: undefined, + triggerID, + messageID: toolUseId ?? 'toolUse', + userIntent: undefined, + codeBlockLanguage: undefined, + contextList: undefined, + }, + tabID + ) + ) + } + public sendErrorMessage( errorMessage: string | undefined, tabID: string, @@ -591,14 +626,66 @@ export class Messenger { ) } + private sendReadAndListDirToolMessage( + toolUse: ToolUse, + session: ChatSession, + tabID: string, + triggerID: string, + messageIdToUpdate?: string + ) { + const contextList = toolUse.name === ToolType.ListDirectory ? session.readFolders : session.readFiles + const isFileRead = toolUse.name === ToolType.FsRead + const items = isFileRead ? session.readFiles : session.readFolders + const itemCount = items.length + + const title = + itemCount < 1 + ? 'Gathering context' + : isFileRead + ? `${itemCount} file${itemCount > 1 ? 's' : ''} read` + : `${itemCount} ${itemCount === 1 ? 'directory' : 'directories'} listed` + + this.dispatcher.sendToolMessage( + new ToolMessage( + { + message: '', + messageType: 'answer-part', + followUps: undefined, + followUpsHeader: undefined, + relatedSuggestions: undefined, + triggerID, + messageID: messageIdToUpdate ?? toolUse?.toolUseId ?? '', + userIntent: undefined, + codeBlockLanguage: undefined, + contextList, + canBeVoted: false, + buttons: undefined, + fullWidth: false, + padding: false, + codeBlockActions: undefined, + rootFolderTitle: title, + }, + tabID + ) + ) + } + public sendPartialToolLog( message: string, tabID: string, triggerID: string, toolUse: ToolUse | undefined, + session: ChatSession, + messageIdToUpdate: string | undefined, validation: CommandValidation, changeList?: Change[] ) { + // Handle read tool and list directory messages + if (toolUse?.name === ToolType.FsRead || toolUse?.name === ToolType.ListDirectory) { + return this.sendReadAndListDirToolMessage(toolUse, session, tabID, triggerID, messageIdToUpdate) + } + + // Handle file write tool, execute bash tool and bash command output log messages const buttons: ChatItemButton[] = [] let header: ChatItemHeader | undefined = undefined if (toolUse?.name === ToolType.ExecuteBash && message.startsWith('```shell')) { diff --git a/packages/core/src/codewhispererChat/tools/chatStream.ts b/packages/core/src/codewhispererChat/tools/chatStream.ts index 5c0ed7eb2f6..909963d1527 100644 --- a/packages/core/src/codewhispererChat/tools/chatStream.ts +++ b/packages/core/src/codewhispererChat/tools/chatStream.ts @@ -9,6 +9,7 @@ import { Messenger } from '../controllers/chat/messenger/messenger' import { ToolUse } from '@amzn/codewhisperer-streaming' import { CommandValidation } from './executeBash' import { Change } from 'diff' +import { ChatSession } from '../clients/chat/v0/chat' /** * A writable stream that feeds each chunk/line to the chat UI. @@ -22,6 +23,10 @@ export class ChatStream extends Writable { private readonly tabID: string, private readonly triggerID: string, private readonly toolUse: ToolUse | undefined, + private readonly session: ChatSession, + private readonly messageIdToUpdate: string | undefined, + // emitEvent decides to show the streaming message or read/list directory tool message to the user. + private readonly emitEvent: boolean, private readonly validation: CommandValidation, private readonly isReadorList: boolean, private readonly changeList?: Change[], @@ -53,6 +58,8 @@ export class ChatStream extends Writable { this.tabID, this.triggerID, this.toolUse, + this.session, + this.messageIdToUpdate, this.validation, this.changeList ) diff --git a/packages/core/src/codewhispererChat/tools/fsRead.ts b/packages/core/src/codewhispererChat/tools/fsRead.ts index 05519641bd0..ecf90ea6dd3 100644 --- a/packages/core/src/codewhispererChat/tools/fsRead.ts +++ b/packages/core/src/codewhispererChat/tools/fsRead.ts @@ -7,7 +7,6 @@ import { getLogger } from '../../shared/logger/logger' import fs from '../../shared/fs/fs' import { InvokeOutput, OutputKind, sanitizePath } from './toolShared' import { Writable } from 'stream' -import path from 'path' export interface FsReadParams { path: string @@ -48,23 +47,7 @@ export class FsRead { } public queueDescription(updates: Writable): void { - const fileName = path.basename(this.fsPath) - const fileUri = vscode.Uri.file(this.fsPath) - updates.write(`Reading file: [${fileName}](${fileUri}), `) - - const [start, end] = this.readRange ?? [] - - if (start && end) { - updates.write(`from line ${start} to ${end}`) - } else if (start) { - if (start > 0) { - updates.write(`from line ${start} to end of file`) - } else { - updates.write(`${start} line from the end of file to end of file`) - } - } else { - updates.write('all lines') - } + updates.write('') updates.end() } diff --git a/packages/core/src/codewhispererChat/tools/listDirectory.ts b/packages/core/src/codewhispererChat/tools/listDirectory.ts index 96ac6972bdc..a7379169a4d 100644 --- a/packages/core/src/codewhispererChat/tools/listDirectory.ts +++ b/packages/core/src/codewhispererChat/tools/listDirectory.ts @@ -51,12 +51,12 @@ export class ListDirectory { public queueDescription(updates: Writable): void { const fileName = path.basename(this.fsPath) if (this.maxDepth === undefined) { - updates.write(`Listing directory recursively: ${fileName}`) + updates.write(`Analyzing directories recursively: ${fileName}`) } else if (this.maxDepth === 0) { - updates.write(`Listing directory: ${fileName}`) + updates.write(`Analyzing directory: ${fileName}`) } else { const level = this.maxDepth > 1 ? 'levels' : 'level' - updates.write(`Listing directory: ${fileName} limited to ${this.maxDepth} subfolder ${level}`) + updates.write(`Analyzing directory: ${fileName} limited to ${this.maxDepth} subfolder ${level}`) } updates.end() } diff --git a/packages/core/src/codewhispererChat/view/connector/connector.ts b/packages/core/src/codewhispererChat/view/connector/connector.ts index 6f04d09cd46..6e2aa92fe63 100644 --- a/packages/core/src/codewhispererChat/view/connector/connector.ts +++ b/packages/core/src/codewhispererChat/view/connector/connector.ts @@ -404,6 +404,10 @@ export class ChatMessage extends UiMessage { } } +export class ToolMessage extends ChatMessage { + override type = 'toolMessage' +} + export interface FollowUp { readonly type: string readonly pillText: string @@ -458,6 +462,10 @@ export class AppToWebViewMessageDispatcher { this.appsToWebViewMessagePublisher.publish(message) } + public sendToolMessage(message: ToolMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + public sendEditorContextCommandMessage(message: EditorContextCommandMessage) { this.appsToWebViewMessagePublisher.publish(message) } From 92531580571230a9797ce04580d09ec22a21bc93 Mon Sep 17 00:00:00 2001 From: laileni Date: Thu, 10 Apr 2025 19:57:17 -0700 Subject: [PATCH 4/4] Minor UX Changes --- .../webview/ui/apps/cwChatConnector.ts | 34 +++++++++---------- .../controllers/chat/messenger/messenger.ts | 24 +++++++++---- .../codewhispererChat/tools/tool_index.json | 2 +- 3 files changed, 34 insertions(+), 26 deletions(-) diff --git a/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts b/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts index 2fae85f483a..174dfa1a9a1 100644 --- a/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts @@ -401,45 +401,43 @@ export class Connector extends BaseConnector { } break case 'run-shell-command': - answer.header = { - body: 'shell', - status: { + if (answer.header) { + answer.header.status = { icon: 'ok' as MynahIconsType, text: 'Accepted', status: 'success', - }, + } + answer.header.buttons = [] } break case 'reject-shell-command': - answer.header = { - body: 'shell', - status: { + if (answer.header) { + answer.header.status = { icon: 'cancel' as MynahIconsType, text: 'Rejected', status: 'error', - }, + } + answer.header.buttons = [] } break case 'confirm-tool-use': - answer.header = { - icon: 'shell' as MynahIconsType, - body: 'shell', - status: { + if (answer.header) { + answer.header.status = { icon: 'ok' as MynahIconsType, text: 'Accepted', status: 'success', - }, + } + answer.header.buttons = [] } break case 'reject-tool-use': - answer.header = { - icon: 'shell' as MynahIconsType, - body: 'shell', - status: { + if (answer.header) { + answer.header.status = { icon: 'cancel' as MynahIconsType, text: 'Rejected', status: 'error', - }, + } + answer.header.buttons = [] } break default: diff --git a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts index 740316989db..839f8ea087a 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts @@ -55,6 +55,8 @@ import { MynahIconsType, DetailedList, MynahUIDataModel, + MynahIcons, + Status, } from '@aws/mynah-ui' import { Database } from '../../../../shared/db/chatDb/chatDb' import { TabType } from '../../../../amazonq/webview/ui/storages/tabsStorage' @@ -734,14 +736,23 @@ export class Messenger { status: 'clear', icon: 'cancel' as MynahIconsType, }, - { - id: 'accept-code-diff', - status: 'clear', - icon: 'ok' as MynahIconsType, - }, ] + const status: { + icon?: MynahIcons | MynahIconsType + status?: { + status?: Status + icon?: MynahIcons | MynahIconsType + text?: string + } + } = { + status: { + text: 'Accepted', + status: 'success', + }, + } header = { buttons, + ...status, fileList, } } else if (toolUse?.name === ToolType.ListDirectory || toolUse?.name === ToolType.FsRead) { @@ -750,7 +761,7 @@ export class Messenger { { id: 'confirm-tool-use', text: localize('AWS.generic.run', 'Run'), - status: 'main', + status: 'clear', icon: 'play' as MynahIconsType, }, { @@ -761,7 +772,6 @@ export class Messenger { }, ] header = { - icon: 'shell' as MynahIconsType, body: 'shell', buttons, } diff --git a/packages/core/src/codewhispererChat/tools/tool_index.json b/packages/core/src/codewhispererChat/tools/tool_index.json index 89f13622303..6af419edf6f 100644 --- a/packages/core/src/codewhispererChat/tools/tool_index.json +++ b/packages/core/src/codewhispererChat/tools/tool_index.json @@ -10,7 +10,7 @@ "type": "string" }, "readRange": { - "description": "Optional parameter when reading files.\n * If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[startLine, -1]` shows all lines from `startLine` to the end of the file.", + "description": "Optional parameter when reading files.\n * If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[startLine, -1]` shows all lines from `startLine` to the end of the file. If the whole file is too large, try reading 4000 lines at once, for example: after reading [1, 4000], read [4000, 8000] next and repeat. You should read atleast 250 lines per invocation of the tool. In some cases, if reading a range of lines results in too many invocations instead attempt to read 4000 lines.", "items": { "type": "integer" },