Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 79 additions & 2 deletions src/components/__test__/chat-item/prompt-text-input.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}
Expand All @@ -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', () => {
Expand Down
37 changes: 25 additions & 12 deletions src/components/chat-item/prompt-input/prompt-text-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions ui-tests/__test__/flows/quick-action-commands-header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
Loading