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 <makeittech@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-08-03 14:24:22 +00:00
co-authored by Serhii Dziupin
parent 3ad3f21024
commit 682c42df8f
3 changed files with 198 additions and 17 deletions
+9 -2
View File
@@ -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' },
],
+170 -9
View File
@@ -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([]);
+19 -6
View File
@@ -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;