From 682c42df8f0ed282026aee152c9fefab9de3896e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 14:24:22 +0000 Subject: [PATCH] fix(ui): harden Ctrl+L selection capture and menu delivery Cover CodeMirror and DOM capture paths in tests, collapse text-control selections after capture, and deliver the desktop Edit-menu action over a single IPC channel so append cannot double-fire. Co-authored-by: Serhii Dziupin --- packages/electron/main.mjs | 11 +- .../ui/src/lib/addSelectionToChat.test.ts | 179 +++++++++++++++++- packages/ui/src/lib/addSelectionToChat.ts | 25 ++- 3 files changed, 198 insertions(+), 17 deletions(-) diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 31731358..c1004947 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -2250,6 +2250,13 @@ const dispatchMenuAction = (action) => { dispatchDomEventToWindow(target, 'openchamber:menu-action', action); }; +// Append-style menu actions must reach the renderer exactly once. Dual IPC+DOM +// delivery (dispatchMenuAction) would insert the selection twice. +const dispatchAddSelectionToChat = () => { + const target = getMenuTargetWindow(); + if (target) emitToWindow(target, 'openchamber:menu-action', 'add-selection-to-chat'); +}; + // Mini-chat draft windows are not deduplicated, so this must reach the renderer // exactly once — emitToWindow alone (no DOM-event double dispatch). The renderer // resolves the active directory/project and opens the window. @@ -4559,7 +4566,7 @@ const buildMacMenu = () => { { type: 'separator' }, { role: 'cut' }, { label: 'Copy', accelerator: 'Cmd+C', click: () => handleCopyAction() }, - { label: 'Add Selection to Chat', accelerator: 'Cmd+L', registerAccelerator: false, click: () => dispatchAction('add-selection-to-chat') }, + { label: 'Add Selection to Chat', accelerator: 'Cmd+L', registerAccelerator: false, click: () => dispatchAddSelectionToChat() }, { role: 'paste' }, { role: 'selectAll' }, ], @@ -4657,7 +4664,7 @@ const buildAutoHiddenMenu = () => { { type: 'separator' }, { role: 'cut' }, { label: 'Copy', accelerator: 'Ctrl+C', click: () => handleCopyAction() }, - { label: 'Add Selection to Chat', accelerator: 'Ctrl+L', registerAccelerator: false, click: () => dispatchAction('add-selection-to-chat') }, + { label: 'Add Selection to Chat', accelerator: 'Ctrl+L', registerAccelerator: false, click: () => dispatchAddSelectionToChat() }, { role: 'paste' }, { role: 'selectAll' }, ], diff --git a/packages/ui/src/lib/addSelectionToChat.test.ts b/packages/ui/src/lib/addSelectionToChat.test.ts index c95cf09f..6acc225a 100644 --- a/packages/ui/src/lib/addSelectionToChat.test.ts +++ b/packages/ui/src/lib/addSelectionToChat.test.ts @@ -4,6 +4,23 @@ const focusChatInputCalls: number[] = []; const pendingInputCalls: Array<{ text: string | null; mode?: string }> = []; const activeMainTabCalls: string[] = []; const sessionSwitcherCalls: boolean[] = []; +const codeMirrorDispatches: Array<{ selection: { anchor: number } }> = []; + +type MockCodeMirrorView = { + state: { + selection: { main: { from: number; to: number } }; + sliceDoc: (from: number, to: number) => string; + }; + dispatch: (transaction: { selection: { anchor: number } }) => void; +}; + +let codeMirrorView: MockCodeMirrorView | null = null; + +mock.module('@codemirror/view', () => ({ + EditorView: { + findFromDOM: () => codeMirrorView, + }, +})); mock.module('@/components/chat/composer/editor/dom', () => ({ focusChatInput: () => { @@ -39,13 +56,28 @@ const { addSelectionToChat, captureSelectionMarkdownForChat } = await import('./ const originalDocument = globalThis.document; const originalWindow = globalThis.window; -const installEmptySelectionEnvironment = (activeElement: Element | null = null) => { +const installSelectionEnvironment = (options: { + activeElement?: Element | null; + focusedCodeMirror?: Element | null; + selection?: Selection | null; +} = {}) => { + const { + activeElement = null, + focusedCodeMirror = null, + selection = null, + } = options; + const documentLike = { activeElement, - querySelector: () => null, + querySelector: (selector: string) => { + if (selector === '.cm-editor.cm-focused') { + return focusedCodeMirror; + } + return null; + }, }; const windowLike = { - getSelection: () => null, + getSelection: () => selection, }; Object.defineProperty(globalThis, 'document', { value: documentLike, configurable: true }); Object.defineProperty(globalThis, 'window', { value: windowLike, configurable: true }); @@ -56,6 +88,8 @@ const clearCalls = () => { pendingInputCalls.length = 0; activeMainTabCalls.length = 0; sessionSwitcherCalls.length = 0; + codeMirrorDispatches.length = 0; + codeMirrorView = null; }; afterEach(() => { @@ -69,11 +103,11 @@ describe('captureSelectionMarkdownForChat', () => { }); test('returns null when nothing is selected', () => { - installEmptySelectionEnvironment(); + installSelectionEnvironment(); expect(captureSelectionMarkdownForChat()).toBeNull(); }); - test('captures a textarea selection outside the composer', () => { + test('captures a textarea selection outside the composer and collapses it', () => { const textarea = { tagName: 'TEXTAREA', value: 'alpha beta gamma', @@ -82,8 +116,10 @@ describe('captureSelectionMarkdownForChat', () => { closest: () => null, } as unknown as HTMLTextAreaElement; - installEmptySelectionEnvironment(textarea); + installSelectionEnvironment({ activeElement: textarea }); expect(captureSelectionMarkdownForChat()).toBe('```md\nbeta\n```'); + expect(textarea.selectionStart).toBe(10); + expect(textarea.selectionEnd).toBe(10); }); test('ignores selections inside the chat composer', () => { @@ -95,7 +131,117 @@ describe('captureSelectionMarkdownForChat', () => { closest: (selector: string) => (selector === '[data-chat-input="true"]' ? textarea : null), } as unknown as HTMLTextAreaElement; - installEmptySelectionEnvironment(textarea); + installSelectionEnvironment({ activeElement: textarea }); + expect(captureSelectionMarkdownForChat()).toBeNull(); + }); + + test('captures a focused CodeMirror selection outside the composer and collapses it', () => { + const focusedEditor = { + closest: () => null, + } as unknown as HTMLElement; + + codeMirrorView = { + state: { + selection: { main: { from: 4, to: 11 } }, + sliceDoc: (from: number, to: number) => 'const x'.slice(0, to - from), + }, + dispatch: (transaction) => { + codeMirrorDispatches.push(transaction); + codeMirrorView!.state.selection.main = { + from: transaction.selection.anchor, + to: transaction.selection.anchor, + }; + }, + }; + + // sliceDoc should return the selected slice; use explicit text instead of slice math. + codeMirrorView.state.sliceDoc = () => 'const x'; + + installSelectionEnvironment({ focusedCodeMirror: focusedEditor }); + expect(captureSelectionMarkdownForChat()).toBe('```\nconst x\n```'); + expect(codeMirrorDispatches).toEqual([{ selection: { anchor: 11 } }]); + expect(codeMirrorView.state.selection.main).toEqual({ from: 11, to: 11 }); + }); + + test('ignores a focused CodeMirror editor inside the chat composer', () => { + const focusedEditor = { + closest: (selector: string) => (selector === '[data-chat-input="true"]' ? focusedEditor : null), + } as unknown as HTMLElement; + + codeMirrorView = { + state: { + selection: { main: { from: 0, to: 5 } }, + sliceDoc: () => 'draft', + }, + dispatch: (transaction) => { + codeMirrorDispatches.push(transaction); + }, + }; + + installSelectionEnvironment({ focusedCodeMirror: focusedEditor }); + expect(captureSelectionMarkdownForChat()).toBeNull(); + expect(codeMirrorDispatches).toEqual([]); + }); + + test('captures a DOM selection from chat-message content and clears it', () => { + const parent = { + closest: (selector: string) => (selector === 'pre code' ? null : null), + }; + const textNode = { + nodeType: 3, + parentElement: parent, + }; + let rangeCount = 1; + let collapsed = false; + const selection = { + get rangeCount() { + return rangeCount; + }, + get isCollapsed() { + return collapsed; + }, + toString: () => 'Hello world', + getRangeAt: () => ({ + commonAncestorContainer: textNode, + startContainer: textNode, + endContainer: textNode, + cloneContents: () => ({ childNodes: [] }), + }), + removeAllRanges: () => { + rangeCount = 0; + collapsed = true; + }, + } as unknown as Selection; + + installSelectionEnvironment({ selection }); + expect(captureSelectionMarkdownForChat()).toBe('```md\nHello world\n```'); + expect(selection.rangeCount).toBe(0); + expect(selection.isCollapsed).toBe(true); + }); + + test('ignores a DOM selection inside the chat composer', () => { + const composerHost = {}; + const parent = { + closest: (selector: string) => (selector === '[data-chat-input="true"]' ? composerHost : null), + }; + const textNode = { + nodeType: 3, + parentElement: parent, + }; + const selection = { + rangeCount: 1, + isCollapsed: false, + toString: () => 'draft', + getRangeAt: () => ({ + commonAncestorContainer: textNode, + startContainer: textNode, + endContainer: textNode, + cloneContents: () => ({ childNodes: [] }), + }), + removeAllRanges: () => undefined, + } as unknown as Selection; + + installSelectionEnvironment({ selection }); expect(captureSelectionMarkdownForChat()).toBeNull(); }); }); @@ -113,7 +259,7 @@ describe('addSelectionToChat', () => { selectionEnd: 8, closest: () => null, } as unknown as HTMLTextAreaElement; - installEmptySelectionEnvironment(textarea); + installSelectionEnvironment({ activeElement: textarea }); expect(addSelectionToChat()).toBe(true); expect(activeMainTabCalls).toEqual(['chat']); @@ -124,8 +270,23 @@ describe('addSelectionToChat', () => { expect(focusChatInputCalls.length).toBe(1); }); + test('second capture after textarea collapse does not append again', () => { + const textarea = { + tagName: 'TEXTAREA', + value: 'selected', + selectionStart: 0, + selectionEnd: 8, + closest: () => null, + } as unknown as HTMLTextAreaElement; + installSelectionEnvironment({ activeElement: textarea }); + + expect(addSelectionToChat()).toBe(true); + expect(addSelectionToChat()).toBe(false); + expect(pendingInputCalls).toEqual([{ text: '```md\nselected\n```', mode: 'append' }]); + }); + test('focuses chat input when nothing is selected', async () => { - installEmptySelectionEnvironment(); + installSelectionEnvironment(); expect(addSelectionToChat()).toBe(false); expect(pendingInputCalls).toEqual([]); diff --git a/packages/ui/src/lib/addSelectionToChat.ts b/packages/ui/src/lib/addSelectionToChat.ts index 6fc8c1ad..ce296569 100644 --- a/packages/ui/src/lib/addSelectionToChat.ts +++ b/packages/ui/src/lib/addSelectionToChat.ts @@ -30,9 +30,16 @@ const readTextControlSelection = (element: Element): string | null => { const tag = element.tagName?.toLowerCase(); if (tag === 'textarea') { const control = element as HTMLTextAreaElement; - return trimSelectionValue( - control.value.slice(control.selectionStart ?? 0, control.selectionEnd ?? 0), - ) || null; + const start = control.selectionStart ?? 0; + const end = control.selectionEnd ?? 0; + const text = trimSelectionValue(control.value.slice(start, end)); + if (!text) { + return null; + } + // Collapse so a duplicate menu delivery cannot append the same range twice. + control.selectionStart = end; + control.selectionEnd = end; + return text; } if (tag === 'input') { @@ -41,9 +48,15 @@ const readTextControlSelection = (element: Element): string | null => { if (!['text', 'search', 'url', 'tel', 'password'].includes(type)) { return null; } - return trimSelectionValue( - control.value.slice(control.selectionStart ?? 0, control.selectionEnd ?? 0), - ) || null; + const start = control.selectionStart ?? 0; + const end = control.selectionEnd ?? 0; + const text = trimSelectionValue(control.value.slice(start, end)); + if (!text) { + return null; + } + control.selectionStart = end; + control.selectionEnd = end; + return text; } return null;