diff --git a/example/src/commands.ts b/example/src/commands.ts index b71dfbfba..dabde5767 100644 --- a/example/src/commands.ts +++ b/example/src/commands.ts @@ -28,5 +28,6 @@ export enum Commands { CLEAR_CONTEXT_ITEMS = '/clear-context-items', CLEAR_LOGS = '/clear-logs', SHOW_CUSTOM_FORM = '/show-custom-form', - VOTE = '/vote' + VOTE = '/vote', + MODIFIED_FILES_DEMO = '/modified-files-demo' } \ No newline at end of file diff --git a/example/src/config.ts b/example/src/config.ts index 4f5c8e020..f14c3d185 100644 --- a/example/src/config.ts +++ b/example/src/config.ts @@ -283,6 +283,11 @@ export const QuickActionCommands: QuickActionCommandGroup[] = [ icon: MynahIcons.FLASH, description: 'Test streaming animation with different speeds. Use tab bar buttons to switch modes.', }, + { + command: Commands.MODIFIED_FILES_DEMO, + icon: MynahIcons.FILE, + description: 'Demo the modified files tracker component above the chat interface.', + }, ], }, ]; diff --git a/example/src/main.ts b/example/src/main.ts index 61096c7a0..116a01f76 100644 --- a/example/src/main.ts +++ b/example/src/main.ts @@ -1259,8 +1259,17 @@ here to see if it gets cut off properly as expected, with an ellipsis through cs `); }, onChatPrompt: (tabId: string, prompt: ChatPrompt) => { - + // Clear modified files tracker only for commands that modify files + const fileModifyingCommands = [ + Commands.MODIFIED_FILES_DEMO, + '/dev', + '/transform', + '/generate' + ]; + if (prompt.command && fileModifyingCommands.includes(prompt.command)) { + mynahUI.clearModifiedFiles(tabId); + } Log(`New prompt on tab: ${tabId}
prompt: ${prompt.prompt !== undefined && prompt.prompt !== '' ? prompt.prompt : '{command only}'}
@@ -1578,6 +1587,9 @@ here to see if it gets cut off properly as expected, with an ellipsis through cs onMessageDismiss: (tabId, messageId) => { Log(`Card dismissed: tabId: ${tabId}, messageId: ${messageId}`); }, + onModifiedFileClick: (tabId, filePath) => { + Log(`Modified file clicked on tab ${tabId}: ${filePath}`); + }, }); setTimeout(() => { @@ -1625,6 +1637,116 @@ here to see if it gets cut off properly as expected, with an ellipsis through cs mynahUI.addChatItem(tabId, exampleVoteChatItem); mynahUI.addChatItem(tabId, defaultFollowUps); break; + case Commands.MODIFIED_FILES_DEMO: + // Demo the modified files tracker + mynahUI.addChatItem(tabId, { + type: ChatItemType.ANSWER, + messageId: generateUID(), + body: 'Demonstrating the modified files tracker. Watch the component above the chat!', + }); + + // Make the component visible and set initial state + mynahUI.updateStore(tabId, { + modifiedFilesVisible: true, + modifiedFilesTitle: 'Work in progress...', + modifiedFilesList: { + filePaths: [], + flatList: true + } + }); + + // Simulate file modifications with delays + setTimeout(() => { + mynahUI.updateStore(tabId, { + modifiedFilesList: { + filePaths: ['src/components/chat-wrapper.ts'], + flatList: true + } + }); + }, 1000); + + setTimeout(() => { + mynahUI.updateStore(tabId, { + modifiedFilesList: { + filePaths: [ + 'src/components/chat-wrapper.ts', + 'src/styles/components/_modified-files-tracker.scss' + ], + flatList: true + } + }); + }, 2000); + + setTimeout(() => { + mynahUI.updateStore(tabId, { + modifiedFilesList: { + filePaths: [ + 'src/components/chat-wrapper.ts', + 'src/styles/components/_modified-files-tracker.scss', + 'src/main.ts' + ], + flatList: true + } + }); + }, 3000); + + setTimeout(() => { + mynahUI.updateStore(tabId, { + modifiedFilesList: { + filePaths: [ + 'src/components/chat-wrapper.ts', + 'src/styles/components/_modified-files-tracker.scss', + 'src/main.ts', + 'example/src/main.ts' + ], + flatList: true, + actions: { + 'src/components/chat-wrapper.ts': [{ name: 'undo', icon: 'undo' }], + 'src/styles/components/_modified-files-tracker.scss': [{ name: 'undo', icon: 'undo' }], + 'src/main.ts': [{ name: 'undo', icon: 'undo' }], + 'example/src/main.ts': [{ name: 'undo', icon: 'undo' }] + } + } + }); + }, 4000); + + setTimeout(() => { + mynahUI.updateStore(tabId, { + modifiedFilesTitle: 'Work done!', + modifiedFilesList: { + filePaths: [ + 'src/components/chat-wrapper.ts', + 'src/styles/components/_modified-files-tracker.scss', + 'src/main.ts', + 'example/src/main.ts' + ], + flatList: true, + actions: { + 'src/components/chat-wrapper.ts': [{ name: 'undo', icon: 'undo' }], + 'src/styles/components/_modified-files-tracker.scss': [{ name: 'undo', icon: 'undo' }], + 'src/main.ts': [{ name: 'undo', icon: 'undo' }], + 'example/src/main.ts': [{ name: 'undo', icon: 'undo' }] + } + } + }); + + // Add the undo all button as a separate chat item + mynahUI.addChatItem(tabId, { + type: ChatItemType.ANSWER, + messageId: generateUID(), + body: '', + buttons: [ + { id: 'undo-all', text: 'Undo All', status: 'clear' } + ] + }); + mynahUI.addChatItem(tabId, { + type: ChatItemType.ANSWER, + messageId: generateUID(), + body: 'Demo complete! The modified files tracker now shows "Work done!" status. Click on any file or use the undo buttons to see the callbacks in action.', + }); + mynahUI.addChatItem(tabId, defaultFollowUps); + }, 5000); + break; case Commands.CARD_WITH_MARKDOWN_LIST: getGenerativeAIAnswer(tabId, sampleMarkdownList); break; diff --git a/example/src/samples/sample-data.ts b/example/src/samples/sample-data.ts index a5b05be28..c59342b55 100644 --- a/example/src/samples/sample-data.ts +++ b/example/src/samples/sample-data.ts @@ -681,6 +681,10 @@ export const defaultFollowUps: ChatItem = { pillText: 'Some auto reply', prompt: 'Some random auto reply here.', }, + { + command: Commands.MODIFIED_FILES_DEMO, + pillText: 'Modified files demo', + }, ], }, }; @@ -2181,7 +2185,7 @@ export const mcpToolRunSampleCardInit:ChatItem = // Summary Card filePaths: ['Running'], details: { 'Running': { - description: 'Work in progress!', + description: 'Work in progress...', icon: null, labelIcon: 'progress', labelIconForegroundStatus: 'info', diff --git a/src/__test__/components/feedback-form/feedback-form-integration.spec.ts b/src/__test__/components/feedback-form/feedback-form-integration.spec.ts index 4823ae255..4054241bc 100644 --- a/src/__test__/components/feedback-form/feedback-form-integration.spec.ts +++ b/src/__test__/components/feedback-form/feedback-form-integration.spec.ts @@ -358,7 +358,7 @@ describe('FeedbackForm Integration Tests', () => { describe('Error Handling', () => { it('should handle missing tab data gracefully', () => { // Override the mock for this specific test - mockTabsStore.getTabDataStore.mockReturnValueOnce(undefined as any); + (mockTabsStore.getTabDataStore as jest.Mock).mockReturnValueOnce(undefined as any); feedbackForm = new FeedbackForm(); diff --git a/src/__test__/components/feedback-form/feedback-form.spec.ts b/src/__test__/components/feedback-form/feedback-form.spec.ts index 74352952d..658248471 100644 --- a/src/__test__/components/feedback-form/feedback-form.spec.ts +++ b/src/__test__/components/feedback-form/feedback-form.spec.ts @@ -347,7 +347,7 @@ describe('FeedbackForm Component', () => { it('should return empty array when tab data store is undefined', () => { // Override the mock for this specific test - mockTabsStore.getTabDataStore.mockReturnValueOnce(undefined as any); + (mockTabsStore.getTabDataStore as jest.Mock).mockReturnValueOnce(undefined as any); feedbackForm = new FeedbackForm(); diff --git a/src/components/__test__/chat-item/chat-item-card-content.spec.ts b/src/components/__test__/chat-item/chat-item-card-content.spec.ts index 61535aa35..4d6e579b1 100644 --- a/src/components/__test__/chat-item/chat-item-card-content.spec.ts +++ b/src/components/__test__/chat-item/chat-item-card-content.spec.ts @@ -19,28 +19,26 @@ describe('ChatItemCardContent Animation Speed', () => { beforeEach(() => { jest.clearAllMocks(); - mockGetInstance.mockReturnValue({ - // @ts-expect-error + (mockGetInstance as jest.Mock).mockReturnValue({ config: { typewriterStackTime: 100, typewriterMaxWordTime: 20, disableTypewriterAnimation: false, } - }); + } as any); document.body.innerHTML = '
'; }); describe('Animation Configuration', () => { it('should use fast animation settings', () => { - mockGetInstance.mockReturnValue({ - // @ts-expect-error + (mockGetInstance as jest.Mock).mockReturnValue({ config: { typewriterStackTime: 100, typewriterMaxWordTime: 20, disableTypewriterAnimation: false, } - }); + } as any); const props: ChatItemCardContentProps = { body: 'Test content', @@ -53,12 +51,11 @@ describe('ChatItemCardContent Animation Speed', () => { }); it('should disable animation when configured', () => { - mockGetInstance.mockReturnValue({ - // @ts-expect-error + (mockGetInstance as jest.Mock).mockReturnValue({ config: { disableTypewriterAnimation: true, } - }); + } as any); const props: ChatItemCardContentProps = { body: 'Test content', @@ -89,10 +86,9 @@ describe('ChatItemCardContent Animation Speed', () => { describe('Default Values', () => { it('should use defaults when config is empty', () => { - mockGetInstance.mockReturnValue({ - // @ts-expect-error + (mockGetInstance as jest.Mock).mockReturnValue({ config: {} - }); + } as any); const props: ChatItemCardContentProps = { body: 'Test content', 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..c3a23215b --- /dev/null +++ b/src/components/__test__/modified-files-tracker.spec.ts @@ -0,0 +1,467 @@ +import { ModifiedFilesTracker } from '../modified-files-tracker'; +import { MynahUIGlobalEvents } from '../../helper/events'; +import { ChatItem, ChatItemType } from '../../static'; + +// Mock the tabs store +jest.mock('../../helper/tabs-store', () => ({ + MynahUITabsStore: { + getInstance: jest.fn(() => ({ + getTabDataStore: jest.fn(() => ({ + subscribe: jest.fn(), + getValue: jest.fn((key: string) => { + if (key === 'chatItems') { + return []; + } + return ''; + }) + })) + })) + } +})); + +// Mock global events +jest.mock('../../helper/events', () => ({ + MynahUIGlobalEvents: { + getInstance: jest.fn(() => ({ + dispatch: jest.fn() + })) + } +})); + +// Mock CollapsibleContent +jest.mock('../collapsible-content', () => ({ + CollapsibleContent: jest.fn().mockImplementation(() => ({ + render: { + querySelector: jest.fn(() => ({ + innerHTML: '', + appendChild: jest.fn() + })) + }, + updateTitle: jest.fn() + })) +})); + +describe('ModifiedFilesTracker', () => { + let mockDispatch: jest.Mock; + let mockSubscribe: jest.Mock; + let mockGetValue: jest.Mock; + let mockGetTabDataStore: jest.Mock; + + beforeEach(() => { + mockDispatch = jest.fn(); + mockSubscribe = jest.fn(); + mockGetValue = jest.fn((key: string) => { + if (key === 'chatItems') { + return []; + } + return ''; + }); + mockGetTabDataStore = jest.fn(() => ({ + subscribe: mockSubscribe, + getValue: mockGetValue + })); + + (MynahUIGlobalEvents.getInstance as jest.Mock).mockReturnValue({ + dispatch: mockDispatch + }); + + const { MynahUITabsStore } = jest.requireMock('../../helper/tabs-store'); + (MynahUITabsStore.getInstance as jest.Mock).mockReturnValue({ + getTabDataStore: mockGetTabDataStore + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render with default props', () => { + const tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + + expect(tracker.render).toBeDefined(); + expect(tracker.render.classList.contains('mynah-modified-files-tracker-wrapper')).toBeTruthy(); + expect(tracker.render.classList.contains('hidden')).toBeTruthy(); + }); + + it('should render visible when visible prop is true', () => { + const tracker = new ModifiedFilesTracker({ + tabId: 'test-tab', + visible: true + }); + + expect(tracker.render.classList.contains('hidden')).toBeFalsy(); + }); + + it('should subscribe to chatItems and modifiedFilesTitle', () => { + const tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + + expect(tracker).toBeDefined(); + expect(mockSubscribe).toHaveBeenCalledWith('chatItems', expect.any(Function)); + expect(mockSubscribe).toHaveBeenCalledWith('modifiedFilesTitle', expect.any(Function)); + }); + + describe('updateContent', () => { + let tracker: ModifiedFilesTracker; + let mockContentWrapper: any; + + beforeEach(() => { + mockContentWrapper = { + innerHTML: '', + appendChild: jest.fn() + }; + + const { CollapsibleContent } = jest.requireMock('../collapsible-content'); + (CollapsibleContent as jest.Mock).mockImplementation(() => ({ + render: { + querySelector: jest.fn(() => mockContentWrapper) + }, + updateTitle: jest.fn() + })); + + tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + }); + + it('should show empty state when no modified files', () => { + mockGetValue.mockReturnValue([]); + + (tracker as any).updateContent(); + + expect(mockContentWrapper.appendChild).toHaveBeenCalled(); + }); + + it('should render file pills when modified files exist', () => { + const mockChatItems: ChatItem[] = [ + { + type: ChatItemType.ANSWER, + messageId: 'msg-1', + fileList: { + filePaths: [ 'test.ts' ], + details: { + 'test.ts': { + changes: { added: 5, deleted: 2 }, + visibleName: 'test.ts', + icon: 'ok-circled' + } + } + } + } + ]; + + mockGetValue.mockReturnValue(mockChatItems); + + (tracker as any).updateContent(); + + expect(mockContentWrapper.appendChild).toHaveBeenCalled(); + }); + }); + + describe('title updates', () => { + it('should update title when modifiedFilesTitle changes', () => { + const mockUpdateTitle = jest.fn(); + const { CollapsibleContent } = jest.requireMock('../collapsible-content'); + (CollapsibleContent as jest.Mock).mockImplementation(() => ({ + render: { + querySelector: jest.fn(() => ({ innerHTML: '', appendChild: jest.fn() })) + }, + updateTitle: mockUpdateTitle + })); + + const tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + + expect(tracker).toBeDefined(); + + const titleCallback = mockSubscribe.mock.calls.find( + (call: any) => call[0] === 'modifiedFilesTitle' + )?.[1]; + + titleCallback?.('New Title'); + + expect(mockUpdateTitle).toHaveBeenCalledWith('New Title'); + }); + + it('should not update title when empty string is provided', () => { + const mockUpdateTitle = jest.fn(); + const { CollapsibleContent } = jest.requireMock('../collapsible-content'); + (CollapsibleContent as jest.Mock).mockImplementation(() => ({ + render: { + querySelector: jest.fn(() => ({ innerHTML: '', appendChild: jest.fn() })) + }, + updateTitle: mockUpdateTitle + })); + + const tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + + expect(tracker).toBeDefined(); + + const titleCallback = mockSubscribe.mock.calls.find( + (call: any) => call[0] === 'modifiedFilesTitle' + )?.[1]; + + titleCallback?.(''); + + expect(mockUpdateTitle).not.toHaveBeenCalled(); + }); + }); + + describe('setVisible method', () => { + it('should show tracker when setVisible(true) is called', () => { + const tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + + tracker.render.removeClass = jest.fn(); + tracker.setVisible(true); + + expect(tracker.render.removeClass).toHaveBeenCalledWith('hidden'); + }); + + it('should hide tracker when setVisible(false) is called', () => { + const tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + + tracker.render.addClass = jest.fn(); + tracker.setVisible(false); + + expect(tracker.render.addClass).toHaveBeenCalledWith('hidden'); + }); + }); + + describe('edge cases and error handling', () => { + it('should handle null contentWrapper gracefully', () => { + const { CollapsibleContent } = jest.requireMock('../collapsible-content'); + (CollapsibleContent as jest.Mock).mockImplementation(() => ({ + render: { + querySelector: jest.fn(() => null) + }, + updateTitle: jest.fn() + })); + + const tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + + expect(() => (tracker as any).updateContent()).not.toThrow(); + }); + + it('should handle files with deleted status', () => { + const mockContentWrapper = { + innerHTML: '', + appendChild: jest.fn() + }; + + const { CollapsibleContent } = jest.requireMock('../collapsible-content'); + (CollapsibleContent as jest.Mock).mockImplementation(() => ({ + render: { + querySelector: jest.fn(() => mockContentWrapper) + }, + updateTitle: jest.fn() + })); + + const mockChatItems: ChatItem[] = [ + { + type: ChatItemType.ANSWER, + messageId: 'msg-1', + fileList: { + filePaths: [ 'deleted.ts' ], + deletedFiles: [ 'deleted.ts' ], + details: { + 'deleted.ts': { + changes: { added: 0, deleted: 10 }, + visibleName: 'deleted.ts', + icon: 'trash' + } + } + } + } + ]; + + mockGetValue.mockReturnValue(mockChatItems); + + const tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + + (tracker as any).updateContent(); + expect(mockContentWrapper.appendChild).toHaveBeenCalled(); + }); + + it('should handle files with undo buttons', () => { + const mockContentWrapper = { + innerHTML: '', + appendChild: jest.fn() + }; + + const { CollapsibleContent } = jest.requireMock('../collapsible-content'); + (CollapsibleContent as jest.Mock).mockImplementation(() => ({ + render: { + querySelector: jest.fn(() => mockContentWrapper) + }, + updateTitle: jest.fn() + })); + + const mockChatItems: ChatItem[] = [ + { + type: ChatItemType.ANSWER, + messageId: 'msg-1', + header: { + buttons: [ { id: 'undo-changes', text: 'Undo' } ] + }, + fileList: { + filePaths: [ 'test.ts' ], + details: { + 'test.ts': { + changes: { added: 5, deleted: 2 }, + visibleName: 'test.ts', + icon: 'ok-circled' + } + } + } + } + ]; + + mockGetValue.mockReturnValue(mockChatItems); + + const tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + + (tracker as any).updateContent(); + expect(mockContentWrapper.appendChild).toHaveBeenCalled(); + }); + + it('should handle undo-all-changes button', () => { + const mockContentWrapper = { + innerHTML: '', + appendChild: jest.fn() + }; + + const { CollapsibleContent } = jest.requireMock('../collapsible-content'); + (CollapsibleContent as jest.Mock).mockImplementation(() => ({ + render: { + querySelector: jest.fn(() => mockContentWrapper) + }, + updateTitle: jest.fn() + })); + + const mockChatItems: ChatItem[] = [ + { + type: ChatItemType.ANSWER, + messageId: 'msg-1', + header: { + buttons: [ { id: 'undo-all-changes', text: 'Undo All Changes' } ] + }, + fileList: { + filePaths: [ 'test.ts' ], + details: { + 'test.ts': { + changes: { added: 5, deleted: 2 }, + visibleName: 'test.ts' + } + } + } + } + ]; + + mockGetValue.mockReturnValue(mockChatItems); + + const tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + + (tracker as any).updateContent(); + expect(mockContentWrapper.appendChild).toHaveBeenCalled(); + }); + + it('should handle files without icon', () => { + const mockContentWrapper = { + innerHTML: '', + appendChild: jest.fn() + }; + + const { CollapsibleContent } = jest.requireMock('../collapsible-content'); + (CollapsibleContent as jest.Mock).mockImplementation(() => ({ + render: { + querySelector: jest.fn(() => mockContentWrapper) + }, + updateTitle: jest.fn() + })); + + const mockChatItems: ChatItem[] = [ + { + type: ChatItemType.ANSWER, + messageId: 'msg-1', + fileList: { + filePaths: [ 'test.ts' ], + details: { + 'test.ts': { + changes: { added: 5, deleted: 2 }, + visibleName: 'test.ts' + } + } + } + } + ]; + + mockGetValue.mockReturnValue(mockChatItems); + + const tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + + (tracker as any).updateContent(); + expect(mockContentWrapper.appendChild).toHaveBeenCalled(); + }); + + it('should handle files with clickable false', () => { + const mockContentWrapper = { + innerHTML: '', + appendChild: jest.fn() + }; + + const { CollapsibleContent } = jest.requireMock('../collapsible-content'); + (CollapsibleContent as jest.Mock).mockImplementation(() => ({ + render: { + querySelector: jest.fn(() => mockContentWrapper) + }, + updateTitle: jest.fn() + })); + + const mockChatItems: ChatItem[] = [ + { + type: ChatItemType.ANSWER, + messageId: 'msg-1', + fileList: { + filePaths: [ 'test.ts' ], + details: { + 'test.ts': { + changes: { added: 5, deleted: 2 }, + visibleName: 'test.ts', + clickable: false + } + } + } + } + ]; + + mockGetValue.mockReturnValue(mockChatItems); + + const tracker = new ModifiedFilesTracker({ + tabId: 'test-tab' + }); + + (tracker as any).updateContent(); + expect(mockContentWrapper.appendChild).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/chat-item/chat-item-card.ts b/src/components/chat-item/chat-item-card.ts index d68e08358..55b4eda0e 100644 --- a/src/components/chat-item/chat-item-card.ts +++ b/src/components/chat-item/chat-item-card.ts @@ -320,11 +320,19 @@ export class ChatItemCard { if (header.fileList?.details?.[filePath]?.clickable === false) { return; } + console.log('[ChatItemCard] File pill clicked:', { + tabId: this.props.tabId, + messageId: this.props.chatItem.messageId, + filePath, + deleted: isDeleted, + fileDetails: header.fileList?.details?.[filePath] + }); MynahUIGlobalEvents.getInstance().dispatch(MynahEventNames.FILE_CLICK, { tabId: this.props.tabId, messageId: this.props.chatItem.messageId, filePath, - deleted: isDeleted + deleted: isDeleted, + fileDetails: header.fileList?.details?.[filePath] }); }, ...(description !== undefined @@ -805,6 +813,13 @@ export class ChatItemCard { formItems: this.chatFormItems, buttons: [], onActionClick: action => { + console.log('[ChatItemCard] Button clicked:', { + tabId: this.props.tabId, + messageId: this.props.chatItem.messageId, + actionId: action.id, + actionText: action.text, + formItemValues: this.chatFormItems !== null ? this.chatFormItems.getAllValues() : {} + }); MynahUIGlobalEvents.getInstance().dispatch(MynahEventNames.BODY_ACTION_CLICKED, { tabId: this.props.tabId, messageId: this.props.chatItem.messageId, diff --git a/src/components/chat-item/chat-item-tree-file.ts b/src/components/chat-item/chat-item-tree-file.ts index facf4d15c..a049b12f2 100644 --- a/src/components/chat-item/chat-item-tree-file.ts +++ b/src/components/chat-item/chat-item-tree-file.ts @@ -42,9 +42,14 @@ export class ChatItemTreeFile { click: () => { this.hideTooltip(); if (this.props.details?.clickable !== false) { + const fileMessageId = this.props.details?.data?.messageId ?? this.props.messageId; + console.log('[ChatItemTreeFile] File clicked - originalFilePath:', this.props.originalFilePath); + console.log('[ChatItemTreeFile] File clicked - details.data.fullPath:', this.props.details?.data?.fullPath); + console.log('[ChatItemTreeFile] File clicked - details.description:', this.props.details?.description); + console.log('[ChatItemTreeFile] File clicked - using messageId:', fileMessageId); MynahUIGlobalEvents.getInstance().dispatch(MynahEventNames.FILE_CLICK, { tabId: this.props.tabId, - messageId: this.props.messageId, + messageId: fileMessageId, filePath: this.props.originalFilePath, deleted: this.props.deleted, fileDetails: this.props.details @@ -139,9 +144,17 @@ export class ChatItemTreeFile { onClick: (e) => { cancelEvent(e); this.hideTooltip(); + const fileMessageId = this.props.details?.data?.messageId ?? this.props.messageId; + console.log('[ChatItemTreeFile] File action clicked:', { + tabId: this.props.tabId, + messageId: fileMessageId, + filePath: this.props.originalFilePath, + actionName: action.name, + actionDetails: action + }); MynahUIGlobalEvents.getInstance().dispatch(MynahEventNames.FILE_ACTION_CLICK, { tabId: this.props.tabId, - messageId: this.props.messageId, + messageId: fileMessageId, filePath: this.props.originalFilePath, actionName: action.name, }); diff --git a/src/components/chat-item/chat-wrapper.ts b/src/components/chat-item/chat-wrapper.ts index ebcc1d4ee..d5c09312b 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 { @@ -58,6 +59,7 @@ export class ChatWrapper { 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 +94,18 @@ 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, + visible: false + }); + MynahUITabsStore.getInstance().addListenerToDataStore(this.props.tabId, 'modifiedFilesVisible', (visible: boolean) => { + this.modifiedFilesTracker.setVisible(visible); + }); + 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 +322,7 @@ export class ChatWrapper { } }).render, this.promptStickyCard, + this.modifiedFilesTracker.render, this.promptInputElement, this.footerSpacer, this.promptInfo, @@ -537,4 +550,8 @@ export class ChatWrapper { this.dragOverlayContent.style.display = visible ? 'flex' : 'none'; this.dragBlurOverlay.style.display = visible ? 'block' : 'none'; } + + public setModifiedFilesTrackerVisible (visible: boolean): void { + this.modifiedFilesTracker.setVisible(visible); + } } 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..4b99558e9 --- /dev/null +++ b/src/components/modified-files-tracker.ts @@ -0,0 +1,133 @@ +/*! + * 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 { ChatItemContent, ChatItemButton, MynahEventNames } from '../static'; +import testIds from '../helper/test-ids'; +import { MynahUITabsStore } from '../helper/tabs-store'; +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; + visible?: boolean; +} + +export class ModifiedFilesTracker { + render: ExtendedHTMLElement; + private readonly props: ModifiedFilesTrackerProps; + private readonly collapsibleContent: CollapsibleContent; + public titleText: string = 'No files modified!'; + + constructor (props: ModifiedFilesTrackerProps) { + StyleLoader.getInstance().load('components/_modified-files-tracker.scss'); + this.props = { visible: false, ...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', + ...(this.props.visible === true ? [] : [ 'hidden' ]) + ], + testId: testIds.modifiedFilesTracker.container, + children: [ this.collapsibleContent.render ] + }); + + const tabDataStore = MynahUITabsStore.getInstance().getTabDataStore(this.props.tabId); + + tabDataStore.subscribe('modifiedFilesList', (fileList: ChatItemContent['fileList'] | null) => { + this.renderModifiedFiles(fileList); + }); + + tabDataStore.subscribe('modifiedFilesTitle', (newTitle: string) => { + if (newTitle !== '') { + this.collapsibleContent.updateTitle(newTitle); + } + }); + + this.renderModifiedFiles(tabDataStore.getValue('modifiedFilesList')); + } + + private renderModifiedFiles (fileList: ChatItemContent['fileList'] | null): void { + const contentWrapper = this.collapsibleContent.render.querySelector('.mynah-collapsible-content-label-content-wrapper'); + if (contentWrapper == null) return; + + contentWrapper.innerHTML = ''; + + if ((fileList?.filePaths?.length ?? 0) > 0 && fileList != null) { + this.renderFilePills(contentWrapper, fileList); + } 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 renderFilePills (contentWrapper: Element, fileList: NonNullable & { messageId?: string }): void { + const messageId = fileList.messageId ?? `modified-files-tracker-${this.props.tabId}`; + + // Render the file tree with actions and buttons as provided by the data + contentWrapper.appendChild(new ChatItemTreeViewWrapper({ + tabId: this.props.tabId, + messageId, + files: fileList.filePaths ?? [], + cardTitle: '', + rootTitle: fileList.rootFolderTitle, + deletedFiles: fileList.deletedFiles ?? [], + flatList: fileList.flatList ?? true, + actions: (fileList as any).actions ?? {}, + details: fileList.details ?? {}, + hideFileCount: fileList.hideFileCount ?? true, + collapsed: fileList.collapsed ?? false, + referenceSuggestionLabel: '', + references: [], + onRootCollapsedStateChange: () => {} + }).render); + + // Render buttons if they exist + const fileListWithButtons = fileList as ChatItemContent['fileList'] & { buttons?: ChatItemButton[] }; + const buttons: ChatItemButton[] | undefined = fileListWithButtons.buttons; + if (Array.isArray(buttons) && buttons.length > 0) { + const buttonsWrapper = new ChatItemButtonsWrapper({ + tabId: this.props.tabId, + buttons, + onActionClick: (action: ChatItemButton) => { + MynahUIGlobalEvents.getInstance().dispatch(MynahEventNames.BODY_ACTION_CLICKED, { + tabId: this.props.tabId, + messageId: (action as any).messageId != null ? (action as any).messageId : messageId, + actionId: action.id, + actionText: action.text + }); + } + }); + contentWrapper.appendChild(buttonsWrapper.render); + } + } + + public setVisible (visible: boolean): void { + if (visible) { + this.render.removeClass('hidden'); + } else { + this.render.addClass('hidden'); + } + } +} diff --git a/src/helper/store.ts b/src/helper/store.ts index e6edae95b..33bd41b1f 100644 --- a/src/helper/store.ts +++ b/src/helper/store.ts @@ -43,7 +43,11 @@ const emptyDataModelObject: Required = { compactMode: false, tabHeaderDetails: null, tabMetadata: {}, - customContextCommand: [] + customContextCommand: [], + modifiedFilesTitle: 'No files modified!', + modifiedFilesVisible: false, + modifiedFilesList: null, + newConversation: false }; const dataModelKeys = Object.keys(emptyDataModelObject); export class EmptyMynahUIDataModel { 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..c65f7e431 100644 --- a/src/main.ts +++ b/src/main.ts @@ -50,6 +50,7 @@ import { StyleLoader } from './helper/style-loader'; import { Icon } from './components/icon'; import { Button } from './components/button'; import { TopBarButtonOverlayProps } from './components/chat-item/prompt-input/prompt-top-bar/top-bar-button'; +// TrackedFile interface removed - now using data-driven approach export { generateUID } from './helper/guid'; export { @@ -96,6 +97,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 { @@ -337,6 +342,11 @@ export interface MynahUIProps { files: FileList, insertPosition: number ) => void; + onModifiedFileClick?: ( + tabId: string, + filePath: string, + eventId?: string + ) => void; } export class MynahUI { @@ -352,6 +362,17 @@ export class MynahUI { private readonly sheet?: Sheet; private readonly chatWrappers: Record = {}; + private logToStorage (message: string): void { + try { + const timestamp = new Date().toISOString(); + const logEntry = `[${timestamp}] ${message}`; + const existingLogs = localStorage.getItem('mynah-modified-files-logs') ?? ''; + localStorage.setItem('mynah-modified-files-logs', existingLogs + logEntry + '\n'); + } catch (error) { + // Ignore storage errors + } + } + constructor (props: MynahUIProps) { StyleLoader.getInstance(props.loadStyles !== false).load('styles.scss'); configureMarked(); @@ -387,7 +408,7 @@ export class MynahUI { props.onStopChatResponse(tabId, this.getUserEventId()); } } - : undefined, + : undefined }); return this.chatWrappers[tabId].render; }) @@ -466,7 +487,7 @@ export class MynahUI { props.onStopChatResponse(tabId, this.getUserEventId()); } } - : undefined, + : undefined }); this.tabContentsWrapper.appendChild(this.chatWrappers[tabId].render); this.focusToInput(tabId); @@ -535,11 +556,7 @@ export class MynahUI { }; private readonly addGlobalListeners = (): void => { - MynahUIGlobalEvents.getInstance().addListener(MynahEventNames.CHAT_PROMPT, (data: { tabId: string; prompt: ChatPrompt }) => { - if (this.props.onChatPrompt !== undefined) { - this.props.onChatPrompt(data.tabId, data.prompt, this.getUserEventId()); - } - }); + MynahUIGlobalEvents.getInstance().addListener(MynahEventNames.CHAT_PROMPT, this.handleChatPrompt); MynahUIGlobalEvents.getInstance().addListener(MynahEventNames.FOLLOW_UP_CLICKED, (data: { tabId: string; @@ -601,6 +618,7 @@ export class MynahUI { actionText?: string; formItemValues?: Record; }) => { + console.log('[MynahUI] BODY_ACTION_CLICKED event received:', data); if (this.props.onInBodyButtonClicked !== undefined) { this.props.onInBodyButtonClicked(data.tabId, data.messageId, { id: data.actionId, @@ -792,6 +810,7 @@ export class MynahUI { }); MynahUIGlobalEvents.getInstance().addListener(MynahEventNames.FILE_CLICK, (data) => { + console.log('[MynahUI] FILE_CLICK event received:', data); if (this.props.onFileClick !== undefined) { this.props.onFileClick( data.tabId, @@ -821,6 +840,7 @@ export class MynahUI { }); MynahUIGlobalEvents.getInstance().addListener(MynahEventNames.FILE_ACTION_CLICK, (data) => { + console.log('[MynahUI] FILE_ACTION_CLICK event received:', data); if (this.props.onFileActionClick !== undefined) { this.props.onFileActionClick( data.tabId, @@ -1208,6 +1228,147 @@ export class MynahUI { }; }; + /** + * Adds a file to the modified files tracker for the specified tab with file type + * @param tabId The tab ID + * @param filePath The path of the file + * @param fileType The type of file change ('created', 'modified', 'deleted') + * @param fullPath Optional full path of the file + * @param toolUseId Optional tool use ID for undo operations + */ + public addFile = (tabId: string, filePath: string, fileType: 'created' | 'modified' | 'deleted' = 'modified', fullPath?: string, toolUseId?: string): void => { + // No-op: now handled by data-driven approach through ChatItem.fileList + }; + + /** + * Adds a file to the modified files tracker for the specified tab + * @deprecated Use addFile() instead + * @param tabId The tab ID + * @param filePath The path of the modified file + * @param toolUseId Optional tool use ID for undo operations + */ + public addModifiedFile = (tabId: string, filePath: string, toolUseId?: string): void => { + this.addFile(tabId, filePath, 'modified', toolUseId); + }; + + /** + * Removes a file from the modified files tracker for the specified tab + * @param tabId The tab ID + * @param filePath The path of the file to remove + */ + public removeFile = (tabId: string, filePath: string): void => { + // No-op: now handled by data-driven approach + }; + + /** + * Removes a file from the modified files tracker for the specified tab + * @deprecated Use removeFile() instead + * @param tabId The tab ID + * @param filePath The path of the file to remove + */ + public removeModifiedFile = (tabId: string, filePath: string): void => { + this.removeFile(tabId, filePath); + }; + + /** + * Sets the work in progress status for the files tracker + * @param tabId The tab ID + * @param inProgress Whether work is in progress + */ + public setFilesWorkInProgress = (tabId: string, inProgress: boolean): void => { + // No-op: work in progress functionality removed + }; + + /** + * Sets the work in progress status for the modified files tracker + * @deprecated Use setFilesWorkInProgress() instead + * @param tabId The tab ID + * @param inProgress Whether work is in progress + */ + public setModifiedFilesWorkInProgress = (tabId: string, inProgress: boolean): void => { + this.setFilesWorkInProgress(tabId, inProgress); + }; + + /** + * Clears all files for the specified tab + * @param tabId The tab ID + */ + public clearFiles = (tabId: string): void => { + // No-op: now handled by data-driven approach + }; + + /** + * Clears all modified files for the specified tab + * @deprecated Use clearFiles() instead + * @param tabId The tab ID + */ + public clearModifiedFiles = (tabId: string): void => { + this.clearFiles(tabId); + }; + + private readonly handleChatPrompt = (data: { tabId: string; prompt: ChatPrompt }): void => { + // Clear modified files for file-modifying commands + const fileModifyingCommands = [ '/dev', '/transform', '/generate' ]; + if (data.prompt.command !== null && data.prompt.command !== undefined && fileModifyingCommands.includes(data.prompt.command)) { + this.clearModifiedFiles(data.tabId); + } + + if (this.props.onChatPrompt !== undefined) { + this.props.onChatPrompt(data.tabId, data.prompt, this.getUserEventId()); + } + }; + + /** + * Gets the list of tracked files for the specified tab + * @param tabId The tab ID + * @returns Array of tracked files with types + */ + public getTrackedFiles = (tabId: string): any[] => { + // Return empty array: now handled by data-driven approach + return []; + }; + + /** + * Gets the list of modified files for the specified tab + * @deprecated Use getTrackedFiles() instead + * @param tabId The tab ID + * @returns Array of modified file paths + */ + public getModifiedFiles = (tabId: string): string[] => { + // Return empty array: now handled by data-driven approach + return []; + }; + + /** + * Sets the visibility of the files tracker for the specified tab + * @param tabId The tab ID + * @param visible Whether the tracker should be visible + */ + public setFilesTrackerVisible = (tabId: string, visible: boolean): void => { + if (this.chatWrappers[tabId] != null) { + this.chatWrappers[tabId].setModifiedFilesTrackerVisible(visible); + } + }; + + /** + * Sets the visibility of the modified files tracker for the specified tab + * @deprecated Use setFilesTrackerVisible() instead + * @param tabId The tab ID + * @param visible Whether the tracker should be visible + */ + public setModifiedFilesTrackerVisible = (tabId: string, visible: boolean): void => { + this.setFilesTrackerVisible(tabId, visible); + }; + + /** + * Sets the message ID for the modified files tracker to enable diff mode functionality + * @param tabId The tab ID + * @param messageId The message ID to associate with file clicks + */ + public setMessageId = (tabId: string, messageId: string): void => { + // No-op: now handled by data-driven approach + }; + public destroy = (): void => { // Destroy all chat wrappers Object.values(this.chatWrappers).forEach(chatWrapper => { diff --git a/src/static.ts b/src/static.ts index beaa86040..808946f83 100644 --- a/src/static.ts +++ b/src/static.ts @@ -192,6 +192,22 @@ export interface MynahUIDataModel { * Custom context commands to be inserted into the prompt input. */ customContextCommand?: QuickActionCommand[]; + /** + * Title for the modified files tracker component + */ + modifiedFilesTitle?: string; + /** + * Visibility state for the modified files tracker component + */ + modifiedFilesVisible?: boolean; + /** + * Flag to indicate when a new conversation starts + */ + newConversation?: boolean; + /** + * Modified files data for the tracker component (push mechanism) + */ + modifiedFilesList?: ChatItemContent['fileList']; } export interface MynahUITabStoreTab { diff --git a/src/styles/components/_modified-files-tracker.scss b/src/styles/components/_modified-files-tracker.scss new file mode 100644 index 000000000..3860d99bd --- /dev/null +++ b/src/styles/components/_modified-files-tracker.scss @@ -0,0 +1,42 @@ +/*! + * 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); + } +} + +// 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;