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