From 6bdc3b0cf12ab4c3b464d02bfa0aaef16a8ea31a Mon Sep 17 00:00:00 2001 From: Laxman Reddy Aileni Date: Wed, 15 Jul 2026 02:02:55 +0000 Subject: [PATCH 1/2] fix: preserve undo history when pasting into the chat input The chat prompt input handled paste by manually inserting a text node at the cursor, which bypasses the browser's native undo stack. As a result, undo (Ctrl/Cmd+Z) did not work correctly after pasting. Insert the pasted text with document.execCommand('insertText'), which records the change in the undo history, and fall back to the previous manual insertion when execCommand is unavailable. Adds tests for the execCommand path, the fallback path, and empty-paste handling. --- .../chat-item/prompt-text-input.spec.ts | 81 ++++++++++++++++++- .../prompt-input/prompt-text-input.ts | 37 ++++++--- 2 files changed, 104 insertions(+), 14 deletions(-) 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 bd4fea508..00564c0fd 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 320673329..f20278dd9 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 From 2b3e62266fe0e56c52ac2c66912534bd11097e64 Mon Sep 17 00:00:00 2001 From: Laxman Reddy Aileni Date: Wed, 15 Jul 2026 02:02:56 +0000 Subject: [PATCH 2/2] chore: coerce evaluate result to boolean in quick-action header flow Keeps the repo lint gate green (strict-boolean-expressions). --- ui-tests/__test__/flows/quick-action-commands-header.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui-tests/__test__/flows/quick-action-commands-header.ts b/ui-tests/__test__/flows/quick-action-commands-header.ts index c90e28b6f..a0e6c7ae8 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}`);