diff --git a/src/components/__test__/chat-item/prompt-text-input.spec.ts b/src/components/__test__/chat-item/prompt-text-input.spec.ts index bd4fea50..00564c0f 100644 --- a/src/components/__test__/chat-item/prompt-text-input.spec.ts +++ b/src/components/__test__/chat-item/prompt-text-input.spec.ts @@ -112,7 +112,44 @@ describe('prompt-text-input', () => { expect(inputElement?.getAttribute('placeholder')).toBe('Updated placeholder'); }); - it('handles paste events', () => { + it('inserts pasted text via execCommand so it can be undone', () => { + const testTabId = MynahUITabsStore.getInstance().addTab({ + isSelected: true, + store: {} + }) as string; + + let inputCalled = false; + const textInput = new PromptTextInput({ + tabId: testTabId, + initMaxLength: 1000, + onKeydown: () => {}, + onInput: () => { inputCalled = true; } + }); + + const inputElement = textInput.render.querySelector('.mynah-chat-prompt-input') as HTMLElement; + + const originalExecCommand = document.execCommand; + const execCommandMock = jest.fn(() => true); + document.execCommand = execCommandMock as unknown as typeof document.execCommand; + + const pasteEvent = new Event('paste', { bubbles: true, cancelable: true }); + Object.defineProperty(pasteEvent, 'clipboardData', { + value: { getData: (type: string) => (type === 'text/plain' ? 'pasted text' : '') } + }); + const preventDefaultSpy = jest.spyOn(pasteEvent, 'preventDefault'); + + inputElement.dispatchEvent(pasteEvent); + + // The default paste is prevented and the text is inserted through + // execCommand('insertText'), which keeps it in the browser undo history. + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(execCommandMock).toHaveBeenCalledWith('insertText', false, 'pasted text'); + expect(inputCalled).toBe(true); + + document.execCommand = originalExecCommand; + }); + + it('falls back to manual insertion when execCommand is unavailable', () => { const testTabId = MynahUITabsStore.getInstance().addTab({ isSelected: true, store: {} @@ -125,9 +162,49 @@ describe('prompt-text-input', () => { }); const inputElement = textInput.render.querySelector('.mynah-chat-prompt-input') as HTMLElement; + document.body.appendChild(textInput.render); + + // Place a collapsed selection inside the input. + const range = document.createRange(); + range.selectNodeContents(inputElement); + range.collapse(false); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + + const originalExecCommand = document.execCommand; + document.execCommand = jest.fn(() => false) as unknown as typeof document.execCommand; + + const pasteEvent = new Event('paste', { bubbles: true, cancelable: true }); + Object.defineProperty(pasteEvent, 'clipboardData', { + value: { getData: (type: string) => (type === 'text/plain' ? 'fallback text' : '') } + }); + + inputElement.dispatchEvent(pasteEvent); + + expect(inputElement.textContent).toContain('fallback text'); + + document.execCommand = originalExecCommand; + document.body.removeChild(textInput.render); + }); + + it('ignores paste events without text content', () => { + const testTabId = MynahUITabsStore.getInstance().addTab({ + isSelected: true, + store: {} + }) as string; + let inputCalled = false; + const textInput = new PromptTextInput({ + tabId: testTabId, + initMaxLength: 1000, + onKeydown: () => {}, + onInput: () => { inputCalled = true; } + }); + + const inputElement = textInput.render.querySelector('.mynah-chat-prompt-input') as HTMLElement; inputElement.dispatchEvent(new Event('paste')); - expect(textInput).toBeDefined(); + expect(inputCalled).toBe(false); }); it('manages text input value and clearing', () => { diff --git a/src/components/chat-item/prompt-input/prompt-text-input.ts b/src/components/chat-item/prompt-input/prompt-text-input.ts index 32067332..f20278dd 100644 --- a/src/components/chat-item/prompt-input/prompt-text-input.ts +++ b/src/components/chat-item/prompt-input/prompt-text-input.ts @@ -133,18 +133,31 @@ export class PromptTextInput { // Get plain text from clipboard const text = e.clipboardData?.getData('text/plain'); - if (text != null) { - // Insert text at cursor position - const selection = window.getSelection(); - if ((selection?.rangeCount) != null) { - const range = selection.getRangeAt(0); - range.deleteContents(); - range.insertNode(document.createTextNode(text)); - - // Move cursor to end of inserted text - range.collapse(false); - selection.removeAllRanges(); - selection.addRange(range); + if (text != null && text !== '') { + // Insert via execCommand so the paste is recorded in the browser's + // native undo history. Manually inserting a text node (the fallback + // below) bypasses the undo stack, which breaks Ctrl/Cmd+Z after a paste. + let inserted = false; + try { + inserted = document.execCommand('insertText', false, text); + } catch { + inserted = false; + } + + if (!inserted) { + // Fallback for environments where execCommand is unavailable: + // insert text at the cursor position manually. + const selection = window.getSelection(); + if ((selection?.rangeCount) != null) { + const range = selection.getRangeAt(0); + range.deleteContents(); + range.insertNode(document.createTextNode(text)); + + // Move cursor to end of inserted text + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); + } } // Check if input is empty and trigger input event diff --git a/ui-tests/__test__/flows/quick-action-commands-header.ts b/ui-tests/__test__/flows/quick-action-commands-header.ts index c90e28b6..a0e6c7ae 100644 --- a/ui-tests/__test__/flows/quick-action-commands-header.ts +++ b/ui-tests/__test__/flows/quick-action-commands-header.ts @@ -137,9 +137,9 @@ export const verifyQuickActionCommandsHeaderStatusVariations = async (page: Page let foundStatusClass = false; for (const statusClass of statusClasses) { - const hasStatus = await headerElement.evaluate((el, className) => + const hasStatus = Boolean(await headerElement.evaluate((el, className) => el.classList.contains(className), statusClass - ); + )); if (hasStatus) { foundStatusClass = true; console.log(`Found status class: ${statusClass}`);