diff --git a/example/src/main.ts b/example/src/main.ts index 61096c7a0..3ad05fc87 100644 --- a/example/src/main.ts +++ b/example/src/main.ts @@ -1260,8 +1260,8 @@ here to see if it gets cut off properly as expected, with an ellipsis through cs }, onChatPrompt: (tabId: string, prompt: ChatPrompt) => { - + Log(`New prompt on tab: ${tabId}
prompt: ${prompt.prompt !== undefined && prompt.prompt !== '' ? prompt.prompt : '{command only}'}
command: ${prompt.command ?? '{none}'}
diff --git a/src/components/__test__/modified-files-tracker.spec.ts b/src/components/__test__/modified-files-tracker.spec.ts new file mode 100644 index 000000000..c977cca9f --- /dev/null +++ b/src/components/__test__/modified-files-tracker.spec.ts @@ -0,0 +1,543 @@ +import { ModifiedFilesTracker } from '../modified-files-tracker'; +import { MynahUIGlobalEvents } from '../../helper/events'; +import { ChatItem, ChatItemType, MynahEventNames } from '../../static'; + +// Mock dependencies +jest.mock('../../helper/style-loader', () => ({ + StyleLoader: { + getInstance: jest.fn(() => ({ + load: jest.fn() + })) + } +})); + +jest.mock('../../helper/events', () => ({ + MynahUIGlobalEvents: { + getInstance: jest.fn(() => ({ + dispatch: jest.fn() + })) + } +})); + +jest.mock('../collapsible-content', () => ({ + CollapsibleContent: jest.fn().mockImplementation(() => ({ + render: { + querySelector: jest.fn(() => ({ + innerHTML: '', + appendChild: jest.fn() + })) + }, + updateTitle: jest.fn() + })) +})); + +jest.mock('../chat-item/chat-item-tree-view-wrapper', () => ({ + ChatItemTreeViewWrapper: jest.fn().mockImplementation(() => ({ + render: document.createElement('div') + })) +})); + +jest.mock('../chat-item/chat-item-buttons', () => ({ + ChatItemButtonsWrapper: jest.fn().mockImplementation(() => ({ + render: document.createElement('div') + })) +})); + +describe('ModifiedFilesTracker', () => { + let mockDispatch: jest.Mock; + let mockContentWrapper: any; + let mockCollapsibleContent: any; + + beforeEach(() => { + mockDispatch = jest.fn(); + mockContentWrapper = { + innerHTML: '', + appendChild: jest.fn(), + addEventListener: jest.fn() + }; + + (MynahUIGlobalEvents.getInstance as jest.Mock).mockReturnValue({ + dispatch: mockDispatch + }); + + const { CollapsibleContent } = jest.requireMock('../collapsible-content'); + mockCollapsibleContent = { + render: { + querySelector: jest.fn(() => mockContentWrapper) + }, + updateTitle: jest.fn() + }; + (CollapsibleContent as jest.Mock).mockImplementation(() => mockCollapsibleContent); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('should initialize with basic props', () => { + const tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + + expect(tracker.render).toBeDefined(); + expect(tracker.render.classList.contains('mynah-modified-files-tracker-wrapper')).toBe(true); + expect(tracker.titleText).toBe('No files modified'); + }); + + it('should initialize with chatItem containing fileList', () => { + const chatItem: ChatItem = { + type: ChatItemType.ANSWER, + messageId: 'msg-1', + header: { + fileList: { + filePaths: [ 'test.ts' ] + } + } + }; + + const tracker = new ModifiedFilesTracker({ + tabId: 'test-tab', + chatItem + }); + + expect(tracker).toBeDefined(); + }); + + it('should render empty state when no fileList provided', () => { + const tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + + expect(tracker).toBeDefined(); + expect(mockContentWrapper.appendChild).toHaveBeenCalled(); + }); + }); + + describe('renderModifiedFiles', () => { + let tracker: ModifiedFilesTracker; + + beforeEach(() => { + tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + }); + + it('should handle null fileList', () => { + (tracker as any).renderModifiedFiles(null); + expect(mockContentWrapper.appendChild).toHaveBeenCalled(); + }); + + it('should add files to collection when fileList provided', () => { + const fileList = { + filePaths: [ 'test.ts', 'another.js' ] + }; + + (tracker as any).renderModifiedFiles(fileList, 'msg-1'); + expect((tracker as any).allFiles.size).toBe(1); + }); + + it('should handle empty filePaths array', () => { + const fileList = { + filePaths: [] + }; + + (tracker as any).renderModifiedFiles(fileList, 'msg-1'); + expect(mockContentWrapper.appendChild).toHaveBeenCalled(); + }); + }); + + describe('renderEmptyState', () => { + it('should render empty state message', () => { + const tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + + (tracker as any).renderEmptyState(mockContentWrapper); + expect(mockContentWrapper.appendChild).toHaveBeenCalled(); + }); + }); + + describe('renderAllFilePills', () => { + let tracker: ModifiedFilesTracker; + + beforeEach(() => { + tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + }); + + it('should render file pills for each file', () => { + const fileList = { + filePaths: [ 'test.ts', 'another.js' ], + deletedFiles: [], + actions: {}, + details: {} + }; + + (tracker as any).allFiles.set('msg-1', { fileList, messageId: 'msg-1' }); + (tracker as any).renderAllFilePills(mockContentWrapper); + + expect(mockContentWrapper.appendChild).toHaveBeenCalled(); + }); + + it('should handle deleted files', () => { + const fileList = { + filePaths: [ 'test.ts' ], + deletedFiles: [ 'test.ts' ], + actions: {}, + details: {} + }; + + (tracker as any).allFiles.set('msg-1', { fileList, messageId: 'msg-1' }); + (tracker as any).renderAllFilePills(mockContentWrapper); + + expect(mockContentWrapper.appendChild).toHaveBeenCalled(); + }); + + it('should render buttons when chatItem has header buttons', () => { + const chatItem: ChatItem = { + type: ChatItemType.ANSWER, + messageId: 'msg-1', + header: { + buttons: [ + { id: 'accept', text: 'Accept' }, + { id: 'reject', text: 'Reject' } + ] + } + }; + + tracker = new ModifiedFilesTracker({ + tabId: 'test-tab', + chatItem + }); + + const fileList = { + filePaths: [ 'test.ts' ], + deletedFiles: [], + actions: {}, + details: {} + }; + + (tracker as any).allFiles.set('msg-1', { fileList, messageId: 'msg-1' }); + (tracker as any).renderAllFilePills(mockContentWrapper); + + expect(mockContentWrapper.appendChild).toHaveBeenCalled(); + }); + }); + + describe('getOriginalMessageId', () => { + it('should remove modified-files- prefix', () => { + const tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + + const result = (tracker as any).getOriginalMessageId('modified-files-msg-1'); + expect(result).toBe('msg-1'); + }); + + it('should return original messageId if no prefix', () => { + const tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + + const result = (tracker as any).getOriginalMessageId('msg-1'); + expect(result).toBe('msg-1'); + }); + }); + + describe('renderUndoAllButton', () => { + let tracker: ModifiedFilesTracker; + + beforeEach(() => { + tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + }); + + it('should render undo all button when chatItem has buttons', () => { + const chatItem: ChatItem = { + type: ChatItemType.ANSWER, + messageId: 'msg-1', + buttons: [ + { id: 'undo-all', text: 'Undo All' } + ] + }; + + (tracker as any).renderUndoAllButton(chatItem); + expect(mockContentWrapper.appendChild).toHaveBeenCalled(); + }); + + it('should return early when contentWrapper is null', () => { + mockCollapsibleContent.render.querySelector.mockReturnValue(null); + + const chatItem: ChatItem = { + type: ChatItemType.ANSWER, + messageId: 'msg-1', + buttons: [ + { id: 'undo-all', text: 'Undo All' } + ] + }; + + expect(() => (tracker as any).renderUndoAllButton(chatItem)).not.toThrow(); + }); + + it('should return early when chatItem has no buttons', () => { + const chatItem: ChatItem = { + type: ChatItemType.ANSWER, + messageId: 'msg-1' + }; + + expect(() => (tracker as any).renderUndoAllButton(chatItem)).not.toThrow(); + }); + + it('should return early when messageId is empty', () => { + const chatItem: ChatItem = { + type: ChatItemType.ANSWER, + messageId: '', + buttons: [ + { id: 'undo-all', text: 'Undo All' } + ] + }; + + expect(() => (tracker as any).renderUndoAllButton(chatItem)).not.toThrow(); + }); + }); + + describe('updateTitleText', () => { + let tracker: ModifiedFilesTracker; + + beforeEach(() => { + tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + }); + + it('should update title when chatItem has title', () => { + const chatItem: ChatItem = { + type: ChatItemType.ANSWER, + title: 'Modified Files' + }; + + (tracker as any).updateTitleText(chatItem); + expect(tracker.titleText).toBe('Modified Files'); + expect(mockCollapsibleContent.updateTitle).toHaveBeenCalledWith('Modified Files'); + }); + + it('should not update title when chatItem title is empty', () => { + const chatItem: ChatItem = { + type: ChatItemType.ANSWER, + title: '' + }; + + const originalTitle = tracker.titleText; + (tracker as any).updateTitleText(chatItem); + expect(tracker.titleText).toBe(originalTitle); + }); + + it('should not update title when chatItem has no title', () => { + const chatItem: ChatItem = { + type: ChatItemType.ANSWER + }; + + const originalTitle = tracker.titleText; + (tracker as any).updateTitleText(chatItem); + expect(tracker.titleText).toBe(originalTitle); + }); + }); + + describe('addChatItem', () => { + let tracker: ModifiedFilesTracker; + + beforeEach(() => { + tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + }); + + it('should add chatItem with fileList', () => { + const chatItem: ChatItem = { + type: ChatItemType.ANSWER, + messageId: 'msg-1', + title: 'Test Title', + header: { + fileList: { + filePaths: [ 'test.ts' ] + } + } + }; + + tracker.addChatItem(chatItem); + expect(tracker.titleText).toBe('Test Title'); + expect((tracker as any).props.chatItem).toBe(chatItem); + }); + + it('should add chatItem with only buttons', () => { + const chatItem: ChatItem = { + type: ChatItemType.ANSWER, + messageId: 'msg-1', + buttons: [ + { id: 'undo-all', text: 'Undo All' } + ] + }; + + tracker.addChatItem(chatItem); + expect((tracker as any).props.chatItem).toBe(chatItem); + }); + + it('should handle chatItem without fileList or buttons', () => { + const chatItem: ChatItem = { + type: ChatItemType.ANSWER, + messageId: 'msg-1' + }; + + expect(() => tracker.addChatItem(chatItem)).not.toThrow(); + }); + }); + + describe('clear', () => { + it('should clear all files and reset title', () => { + const tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + + // Add some files first + const fileList = { + filePaths: [ 'test.ts' ] + }; + (tracker as any).allFiles.set('msg-1', { fileList, messageId: 'msg-1' }); + + tracker.clear(); + + expect((tracker as any).allFiles.size).toBe(0); + expect(tracker.titleText).toBe('No files modified'); + expect(mockCollapsibleContent.updateTitle).toHaveBeenCalledWith('No files modified'); + }); + }); + + describe('event handling', () => { + it('should dispatch BODY_ACTION_CLICKED event with filePath', () => { + const chatItem: ChatItem = { + type: ChatItemType.ANSWER, + messageId: 'msg-1', + header: { + buttons: [ + { id: 'accept', text: 'Accept' } + ], + fileList: { + filePaths: [ 'test.ts' ] + } + } + }; + + const tracker = new ModifiedFilesTracker({ + tabId: 'test-tab', + chatItem + }); + + expect(tracker).toBeDefined(); + + // Simulate button click by calling the onActionClick callback + const { ChatItemButtonsWrapper } = jest.requireMock('../chat-item/chat-item-buttons'); + const mockCall = (ChatItemButtonsWrapper as jest.Mock).mock.calls[0]; + const onActionClick = mockCall[0].onActionClick; + + onActionClick({ id: 'accept', text: 'Accept' }); + + expect(mockDispatch).toHaveBeenCalledWith(MynahEventNames.BODY_ACTION_CLICKED, { + tabId: 'test-tab', + messageId: 'msg-1', + actionId: 'accept', + actionText: 'Accept', + filePath: expect.any(String) + }); + }); + + it('should dispatch BODY_ACTION_CLICKED event for undo all button', () => { + const tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + + const chatItem: ChatItem = { + type: ChatItemType.ANSWER, + messageId: 'msg-1', + buttons: [ + { id: 'undo-all', text: 'Undo All' } + ] + }; + + (tracker as any).renderUndoAllButton(chatItem); + + // Simulate button click + const { ChatItemButtonsWrapper } = jest.requireMock('../chat-item/chat-item-buttons'); + const mockCall = (ChatItemButtonsWrapper as jest.Mock).mock.calls[0]; + const onActionClick = mockCall[0].onActionClick; + + onActionClick({ id: 'undo-all', text: 'Undo All' }); + + expect(mockDispatch).toHaveBeenCalledWith(MynahEventNames.BODY_ACTION_CLICKED, { + tabId: 'test-tab', + messageId: 'msg-1', + actionId: 'undo-all', + actionText: 'Undo All' + }); + }); + }); + + describe('edge cases', () => { + it('should handle null contentWrapper gracefully', () => { + mockCollapsibleContent.render.querySelector.mockReturnValue(null); + + const tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + + expect(() => (tracker as any).renderModifiedFiles(null)).not.toThrow(); + }); + + it('should handle fileList with actions and details', () => { + const testTracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + + const fileList = { + filePaths: [ 'test.ts' ], + deletedFiles: [], + actions: { + 'test.ts': [ { id: 'view', text: 'View' } ] + }, + details: { + 'test.ts': { changes: { added: 5, deleted: 2 } } + } + }; + + expect(() => (testTracker as any).renderModifiedFiles(fileList, 'msg-1')).not.toThrow(); + }); + + it('should handle multiple file groups', () => { + const testTracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + + const fileList1 = { + filePaths: [ 'test1.ts' ], + deletedFiles: [], + actions: {}, + details: {} + }; + + const fileList2 = { + filePaths: [ 'test2.ts' ], + deletedFiles: [], + actions: {}, + details: {} + }; + + (testTracker as any).allFiles.set('msg-1', { fileList: fileList1, messageId: 'msg-1' }); + (testTracker as any).allFiles.set('msg-2', { fileList: fileList2, messageId: 'msg-2' }); + + expect(() => (testTracker as any).renderAllFilePills(mockContentWrapper)).not.toThrow(); + expect(mockContentWrapper.appendChild).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/src/components/chat-item/chat-wrapper.ts b/src/components/chat-item/chat-wrapper.ts index ebcc1d4ee..098daf311 100644 --- a/src/components/chat-item/chat-wrapper.ts +++ b/src/components/chat-item/chat-wrapper.ts @@ -30,6 +30,7 @@ import { StyleLoader } from '../../helper/style-loader'; import { Icon } from '../icon'; import { cancelEvent, MynahUIGlobalEvents } from '../../helper/events'; import { TopBarButtonOverlayProps } from './prompt-input/prompt-top-bar/top-bar-button'; +import { ModifiedFilesTracker } from '../modified-files-tracker'; export const CONTAINER_GAP = 12; export interface ChatWrapperProps { @@ -53,11 +54,13 @@ export class ChatWrapper { private lastStreamingChatItemCard: ChatItemCard | null; private lastStreamingChatItemMessageId: string | null; private allRenderedChatItems: Record = {}; + private allRenderedModifiedFileChatItems: Record = {}; render: ExtendedHTMLElement; private readonly dragOverlayContent: HTMLElement; private readonly dragBlurOverlay: HTMLElement; private dragOverlayVisibility: boolean = true; private imageContextFeatureEnabled: boolean = false; + private readonly modifiedFilesTracker: ModifiedFilesTracker; constructor (props: ChatWrapperProps) { StyleLoader.getInstance().load('components/chat/_chat-wrapper.scss'); @@ -92,8 +95,14 @@ export class ChatWrapper { this.imageContextFeatureEnabled = contextCommands.some(group => group.commands.some((cmd: QuickActionCommand) => cmd.command.toLowerCase() === 'image') ); + + this.modifiedFilesTracker = new ModifiedFilesTracker({ + tabId: this.props.tabId + }); + MynahUITabsStore.getInstance().addListenerToDataStore(this.props.tabId, 'chatItems', (chatItems: ChatItem[]) => { const chatItemToInsert: ChatItem = chatItems[chatItems.length - 1]; + if (Object.keys(this.allRenderedChatItems).length === chatItems.length) { const lastItem = this.chatItemsContainer.children.item(Array.from(this.chatItemsContainer.children).length - 1); if (lastItem != null && chatItemToInsert != null) { @@ -310,6 +319,7 @@ export class ChatWrapper { } }).render, this.promptStickyCard, + this.modifiedFilesTracker.render, this.promptInputElement, this.footerSpacer, this.promptInfo, @@ -377,8 +387,26 @@ export class ChatWrapper { }; private readonly insertChatItem = (chatItem: ChatItem): void => { + // Normal flow on initially opening ui requires the currentMessageId; this.removeEmptyCardsAndFollowups(); + const currentMessageId: string = (chatItem.messageId != null && chatItem.messageId !== '') ? chatItem.messageId : `TEMP_${generateUID()}`; + // Check if messageId contains "modified-files-" prefix + // The controller sends a chatItem with specific messageId for the new component "ModifiedFilesTracker" + // When the language-servers send the actual list of modified files, we need to forward it to the ModifiedFilesTracker component + // so it can update its state and re-render itself with the files and undo buttons + if (chatItem.messageId != null && chatItem.messageId !== '' && chatItem.messageId.includes('modified-files-')) { + // Forward only to ModifiedFilesTracker, skip normal flow + if (chatItem.header?.fileList !== null && chatItem.header?.fileList !== undefined) { + this.allRenderedModifiedFileChatItems[chatItem.messageId] = chatItem; + const fileCount = Object.keys(this.allRenderedModifiedFileChatItems).length; + chatItem.title = `${fileCount} file${fileCount === 1 ? '' : 's'} modified`; + } + this.modifiedFilesTracker.addChatItem(chatItem); + return; + } + + // Normal flow for all other chat items const chatItemCard = new ChatItemCard({ tabId: this.props.tabId, chatItem: { @@ -409,6 +437,9 @@ export class ChatWrapper { this.allRenderedChatItems[currentMessageId] = chatItemCard; if (chatItem.type === ChatItemType.PROMPT || chatItem.type === ChatItemType.SYSTEM_PROMPT) { + // Clear modified files tracker on new prompt + this.allRenderedModifiedFileChatItems = {}; + this.modifiedFilesTracker.clear(); // Make sure we align to top when there is a new prompt. // Only if it is a PROMPT! // Check css application diff --git a/src/components/collapsible-content.ts b/src/components/collapsible-content.ts index fd1757b69..9d02d3b45 100644 --- a/src/components/collapsible-content.ts +++ b/src/components/collapsible-content.ts @@ -22,6 +22,7 @@ export class CollapsibleContent { private readonly props: Required; private readonly uid: string; private icon: ExtendedHTMLElement; + private readonly titleTextElement: ExtendedHTMLElement; constructor (props: CollapsibleContentProps) { StyleLoader.getInstance().load('components/_collapsible-content.scss'); this.uid = generateUID(); @@ -71,11 +72,11 @@ export class CollapsibleContent { classNames: [ 'mynah-collapsible-content-label-title-wrapper' ], children: [ this.icon, - { + this.titleTextElement = DomBuilder.getInstance().build({ type: 'span', classNames: [ 'mynah-collapsible-content-label-title-text' ], children: [ this.props.title ] - } + }) ] }, { @@ -88,4 +89,8 @@ export class CollapsibleContent { ], }); } + + public updateTitle (newTitle: string): void { + this.titleTextElement.update({ children: [ newTitle ] }); + } } diff --git a/src/components/modified-files-tracker.ts b/src/components/modified-files-tracker.ts new file mode 100644 index 000000000..8605adb63 --- /dev/null +++ b/src/components/modified-files-tracker.ts @@ -0,0 +1,226 @@ +/*! + * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DomBuilder, ExtendedHTMLElement } from '../helper/dom'; +import { StyleLoader } from '../helper/style-loader'; +import { CollapsibleContent } from './collapsible-content'; +import { ChatItem, ChatItemContent, MynahEventNames } from '../static'; +import testIds from '../helper/test-ids'; +import { ChatItemTreeViewWrapper } from './chat-item/chat-item-tree-view-wrapper'; +import { ChatItemButtonsWrapper } from './chat-item/chat-item-buttons'; +import { MynahUIGlobalEvents } from '../helper/events'; + +export interface ModifiedFilesTrackerProps { + tabId: string; + chatItem?: ChatItem; +} + +export class ModifiedFilesTracker { + render: ExtendedHTMLElement; + private readonly props: ModifiedFilesTrackerProps; + private readonly collapsibleContent: CollapsibleContent; + public titleText: string = 'No files modified'; + private readonly allFiles: Map; messageId: string }> = new Map(); + + constructor (props: ModifiedFilesTrackerProps) { + StyleLoader.getInstance().load('components/_modified-files-tracker.scss'); + this.props = props; + + this.collapsibleContent = new CollapsibleContent({ + title: this.titleText, + initialCollapsedState: true, + children: [], + classNames: [ 'mynah-modified-files-tracker' ], + testId: testIds.modifiedFilesTracker.wrapper + }); + + this.render = DomBuilder.getInstance().build({ + type: 'div', + classNames: [ 'mynah-modified-files-tracker-wrapper' ], + testId: testIds.modifiedFilesTracker.container, + children: [ this.collapsibleContent.render ] + }); + + if ((this.props.chatItem?.header?.fileList) != null) { + this.renderModifiedFiles(this.props.chatItem.header?.fileList, this.props.chatItem.messageId); + } else { + this.renderModifiedFiles(null); + } + } + + private renderModifiedFiles (fileList: ChatItemContent['fileList'] | null, chatItemMessageId?: string): void { + const contentWrapper = this.collapsibleContent.render.querySelector('.mynah-collapsible-content-label-content-wrapper'); + if (contentWrapper == null) return; + + // Prevent label clicks on content wrapper from triggering collapse + contentWrapper.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + + // Add files to the collection if provided + if (fileList != null && (fileList.filePaths?.length ?? 0) > 0) { + const messageId = chatItemMessageId ?? `modified-files-tracker-${this.props.tabId}`; + this.allFiles.set(messageId, { fileList, messageId }); + } + + // Clear and re-render all files + contentWrapper.innerHTML = ''; + + if (this.allFiles.size > 0) { + this.renderAllFilePills(contentWrapper); + } else { + this.renderEmptyState(contentWrapper); + } + } + + private renderEmptyState (contentWrapper: Element): void { + contentWrapper.appendChild(DomBuilder.getInstance().build({ + type: 'div', + classNames: [ 'mynah-modified-files-empty-state' ], + children: [ 'No modified files' ] + })); + } + + private renderAllFilePills (contentWrapper: Element): void { + this.allFiles.forEach(({ fileList, messageId }) => { + const { filePaths = [], deletedFiles = [], actions, details } = fileList; + + // Create a wrapper for each file group + const fileGroupWrapper = DomBuilder.getInstance().build({ + type: 'div', + classNames: [ 'mynah-modified-files-group' ], + children: [] + }); + + // Render each file individually with its own buttons + filePaths.forEach(filePath => { + const singleFileWrapper = DomBuilder.getInstance().build({ + type: 'div', + classNames: [ 'mynah-modified-files-single-file' ], + children: [] + }); + + // Create horizontal container for file and buttons + const horizontalContainer = DomBuilder.getInstance().build({ + type: 'div', + classNames: [ 'mynah-modified-files-horizontal-container' ], + children: [] + }); + + // Render the file tree for single file + const fileTreeWrapper = new ChatItemTreeViewWrapper({ + tabId: this.props.tabId, + messageId: this.getOriginalMessageId(messageId), + files: [ filePath ], + cardTitle: '', + rootTitle: undefined, // No root title for single files + deletedFiles: deletedFiles.filter(df => df === filePath), + flatList: true, // Force flat list for single files + actions: (actions != null) ? { [filePath]: actions[filePath] } : undefined, + details: (details != null) ? { [filePath]: details[filePath] } : undefined, + hideFileCount: true, + collapsed: false, + referenceSuggestionLabel: '', + references: [], + onRootCollapsedStateChange: () => {} + }); + + horizontalContainer.appendChild(fileTreeWrapper.render); + + // Add buttons for this specific file if they exist + if (this.props.chatItem?.header?.buttons != null && Array.isArray(this.props.chatItem.header?.buttons) && this.props.chatItem.header?.buttons.length > 0) { + const buttonsWrapper = new ChatItemButtonsWrapper({ + tabId: this.props.tabId, + classNames: [ 'mynah-modified-files-file-buttons' ], + formItems: null, + buttons: this.props.chatItem.header?.buttons, + onActionClick: action => { + MynahUIGlobalEvents.getInstance().dispatch(MynahEventNames.BODY_ACTION_CLICKED, { + tabId: this.props.tabId, + messageId: this.getOriginalMessageId(messageId), + actionId: action.id, + actionText: action.text, + filePath // Add filePath to identify which file the action is for + }); + } + }); + horizontalContainer.appendChild(buttonsWrapper.render); + } + + singleFileWrapper.appendChild(horizontalContainer); + fileGroupWrapper.appendChild(singleFileWrapper); + }); + + contentWrapper.appendChild(fileGroupWrapper); + }); + } + + private getOriginalMessageId (messageId: string): string { + // Remove "modified-files-" prefix if present + return messageId.startsWith('modified-files-') ? messageId.replace('modified-files-', '') : messageId; + } + + private renderUndoAllButton (chatItem: ChatItem): void { + if (chatItem.messageId === undefined || chatItem.messageId === null || chatItem.messageId === '') { + return; + } + const undoAllMessageId = this.getOriginalMessageId(chatItem.messageId); + const contentWrapper = this.collapsibleContent.render.querySelector('.mynah-collapsible-content-label-content-wrapper'); + if (contentWrapper == null || chatItem.buttons == null) return; + + const buttonContainer = DomBuilder.getInstance().build({ + type: 'div', + classNames: [ 'mynah-modified-files-undo-all-container' ], + children: [] + }); + + const buttonsWrapper = new ChatItemButtonsWrapper({ + tabId: this.props.tabId, + classNames: [ 'mynah-modified-files-undo-all-buttons' ], + formItems: null, + buttons: chatItem.buttons, + onActionClick: action => { + MynahUIGlobalEvents.getInstance().dispatch(MynahEventNames.BODY_ACTION_CLICKED, { + tabId: this.props.tabId, + messageId: undoAllMessageId, + actionId: action.id, + actionText: action.text + }); + } + }); + buttonContainer.appendChild(buttonsWrapper.render); + contentWrapper.appendChild(buttonContainer); + } + + private updateTitleText (chatItem: ChatItem): void { + // check if chatItem + if (chatItem.title !== undefined && chatItem.title !== '') { + this.titleText = chatItem.title; + this.collapsibleContent.updateTitle(this.titleText); + } + } + + public addChatItem (chatItem: ChatItem): void { + this.updateTitleText(chatItem); + if (chatItem.header?.fileList != null) { + // Store the current chatItem for button handling + this.props.chatItem = chatItem; + this.renderModifiedFiles(chatItem.header?.fileList, chatItem.messageId); + } else if (chatItem.buttons != null && Array.isArray(chatItem.buttons) && chatItem.buttons.length > 0) { + // Handle case where only buttons (like undo all) are provided without fileList + this.props.chatItem = chatItem; + this.renderUndoAllButton(chatItem); + } + } + + public clear (): void { + this.allFiles.clear(); + const newTitle = 'No files modified'; + this.titleText = newTitle; + this.collapsibleContent.updateTitle(this.titleText); + this.renderModifiedFiles(null); + } +} diff --git a/src/helper/test-ids.ts b/src/helper/test-ids.ts index f399b8ef3..a218a5a0b 100644 --- a/src/helper/test-ids.ts +++ b/src/helper/test-ids.ts @@ -175,5 +175,13 @@ export default { option: 'dropdown-list-option', optionLabel: 'dropdown-list-option-label', checkIcon: 'dropdown-list-check-icon' + }, + modifiedFilesTracker: { + container: 'modified-files-tracker-container', + wrapper: 'modified-files-tracker-wrapper', + emptyState: 'modified-files-tracker-empty-state', + fileItem: 'modified-files-tracker-file-item', + fileItemAccept: 'modified-files-tracker-file-item-accept', + fileItemUndo: 'modified-files-tracker-file-item-undo' } }; diff --git a/src/main.ts b/src/main.ts index 64e56233b..f43ffe950 100644 --- a/src/main.ts +++ b/src/main.ts @@ -96,6 +96,10 @@ export { ChatItemCardContent, ChatItemCardContentProps } from './components/chat-item/chat-item-card-content'; +export { + ModifiedFilesTracker, + ModifiedFilesTrackerProps +} from './components/modified-files-tracker'; export { default as MynahUITestIds } from './helper/test-ids'; export interface MynahUIProps { @@ -540,7 +544,6 @@ export class MynahUI { this.props.onChatPrompt(data.tabId, data.prompt, this.getUserEventId()); } }); - MynahUIGlobalEvents.getInstance().addListener(MynahEventNames.FOLLOW_UP_CLICKED, (data: { tabId: string; messageId: string; diff --git a/src/styles/components/_modified-files-tracker.scss b/src/styles/components/_modified-files-tracker.scss new file mode 100644 index 000000000..55d0cddf1 --- /dev/null +++ b/src/styles/components/_modified-files-tracker.scss @@ -0,0 +1,69 @@ +/*! + * Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +.mynah-modified-files-tracker-wrapper { + border: var(--mynah-border-width) solid var(--mynah-color-border-default); + border-bottom: none; + border-radius: var(--mynah-input-radius) var(--mynah-input-radius) var(--mynah-card-radius-corner) + var(--mynah-card-radius-corner); + margin: var(--mynah-card-radius-corner) calc(var(--mynah-chat-wrapper-spacing) + var(--mynah-sizing-2)); + + &.hidden { + display: none; + } + + .mynah-collapsible-content-label-content-wrapper { + pointer-events: none; + + * { + pointer-events: auto; + } + } + + .mynah-modified-files-empty-state { + color: var(--mynah-color-text-weak); + font-style: italic; + padding: var(--mynah-sizing-2) var(--mynah-card-radius-corner); + } + + .mynah-modified-files-horizontal-container { + display: flex; + align-items: center; + gap: var(--mynah-sizing-2); + width: 100%; + + .mynah-chat-item-tree-view-wrapper { + flex: 1; + min-width: 0; + } + + .mynah-modified-files-file-buttons { + flex-shrink: 0; + } + } + + .mynah-modified-files-undo-all-container { + display: flex; + justify-content: flex-end; + width: 100%; + } + + .mynah-modified-files-undo-all-buttons { + padding-right: var(--mynah-sizing-2); + margin-top: var(--mynah-sizing-1); + } +} + +// Remove top border radius and padding from chat prompt when modified files tracker is visible +.mynah-modified-files-tracker-wrapper:not(.hidden) + .mynah-chat-prompt-wrapper { + padding-top: var(--mynah-card-radius-corner); + margin-top: calc(var(--mynah-sizing-negative-2, calc(-1 * var(--mynah-sizing-2)))); + + > .mynah-chat-prompt { + border-top-left-radius: var(--mynah-card-radius-corner); + border-top-right-radius: var(--mynah-card-radius-corner); + border-top: none; + } +} diff --git a/src/styles/components/chat/_chat-wrapper.scss b/src/styles/components/chat/_chat-wrapper.scss index 68199e473..d53277b34 100644 --- a/src/styles/components/chat/_chat-wrapper.scss +++ b/src/styles/components/chat/_chat-wrapper.scss @@ -144,6 +144,14 @@ } } + > .mynah-modified-files-tracker-wrapper { + position: relative; + flex-shrink: 0; + padding: 0 var(--mynah-sizing-4); + margin-bottom: var(--mynah-sizing-2); + z-index: 1; + } + &:not(.with-background) { > .mynah-ui-gradient-background { opacity: 0; diff --git a/ui-tests/__snapshots__/chromium/Open-MynahUI-Prompt-navigation-should-navigate-back-to-current-prompt-with-code-attachment/Open-MynahUI-Prompt-navigation-should-navigate-back-to-current-prompt-with-code-attachment-1.png b/ui-tests/__snapshots__/chromium/Open-MynahUI-Prompt-navigation-should-navigate-back-to-current-prompt-with-code-attachment/Open-MynahUI-Prompt-navigation-should-navigate-back-to-current-prompt-with-code-attachment-1.png index df9c57bdc..0274d4bd4 100644 Binary files a/ui-tests/__snapshots__/chromium/Open-MynahUI-Prompt-navigation-should-navigate-back-to-current-prompt-with-code-attachment/Open-MynahUI-Prompt-navigation-should-navigate-back-to-current-prompt-with-code-attachment-1.png and b/ui-tests/__snapshots__/chromium/Open-MynahUI-Prompt-navigation-should-navigate-back-to-current-prompt-with-code-attachment/Open-MynahUI-Prompt-navigation-should-navigate-back-to-current-prompt-with-code-attachment-1.png differ diff --git a/ui-tests/__snapshots__/webkit/Open-MynahUI-Prompt-navigation-should-navigate-back-to-current-prompt-with-code-attachment/Open-MynahUI-Prompt-navigation-should-navigate-back-to-current-prompt-with-code-attachment-1.png b/ui-tests/__snapshots__/webkit/Open-MynahUI-Prompt-navigation-should-navigate-back-to-current-prompt-with-code-attachment/Open-MynahUI-Prompt-navigation-should-navigate-back-to-current-prompt-with-code-attachment-1.png index b8e38f024..a26bb8bd9 100644 Binary files a/ui-tests/__snapshots__/webkit/Open-MynahUI-Prompt-navigation-should-navigate-back-to-current-prompt-with-code-attachment/Open-MynahUI-Prompt-navigation-should-navigate-back-to-current-prompt-with-code-attachment-1.png and b/ui-tests/__snapshots__/webkit/Open-MynahUI-Prompt-navigation-should-navigate-back-to-current-prompt-with-code-attachment/Open-MynahUI-Prompt-navigation-should-navigate-back-to-current-prompt-with-code-attachment-1.png differ