From 44ab20296522f06b5cea40f58e3872f77ba89243 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:55:57 +0000 Subject: [PATCH] fix(native-chat): show spelling suggestions when right-clicking chat inputs (#32035) --- ...teMainWindow-markdown-editor-focus.test.ts | 107 ++++++++++++------ src/main/window/editable-context-menu.ts | 12 +- .../window/main-window-focus-lifecycle.ts | 16 ++- ...ui-bridge-clipboard-and-window-controls.ts | 4 + src/preload/api/ui-window-api.ts | 2 + .../use-native-chat-context-menu.test.tsx | 26 ++++- .../use-native-chat-context-menu.tsx | 34 +++--- .../src/web/preload-api/web-ui-api.ts | 1 + src/shared/rich-markdown-context-menu.ts | 2 + 9 files changed, 148 insertions(+), 56 deletions(-) diff --git a/src/main/window/createMainWindow-markdown-editor-focus.test.ts b/src/main/window/createMainWindow-markdown-editor-focus.test.ts index 62897b32b10..df8657659d3 100644 --- a/src/main/window/createMainWindow-markdown-editor-focus.test.ts +++ b/src/main/window/createMainWindow-markdown-editor-focus.test.ts @@ -21,6 +21,44 @@ import { resetMainWindowMocks } from './createMainWindow-test-harness' +function openMainWindowForContextMenu() { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDevToolsOpened: vi.fn(), + openDevTools: vi.fn(), + closeDevTools: vi.fn(), + replaceMisspelling: vi.fn(), + session: { addWordToSpellCheckerDictionary: vi.fn() } + } + const browserWindowInstance = { + webContents, + on: vi.fn(), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(() => Promise.resolve()), + loadURL: vi.fn(() => Promise.resolve()) + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + createMainWindow(null) + return { webContents, windowHandlers, browserWindowInstance } +} + describe('createMainWindow', () => { beforeEach(() => { resetMainWindowMocks() @@ -467,40 +505,7 @@ describe('createMainWindow', () => { }) it('opens a table-aware context menu synchronously without a renderer query', () => { - const windowHandlers: Record void> = {} - const webContents = { - on: vi.fn((event, handler) => { - windowHandlers[event] = handler - }), - setZoomLevel: vi.fn(), - setBackgroundThrottling: vi.fn(), - invalidate: vi.fn(), - setWindowOpenHandler: vi.fn(), - send: vi.fn(), - isDevToolsOpened: vi.fn(), - openDevTools: vi.fn(), - closeDevTools: vi.fn(), - replaceMisspelling: vi.fn(), - session: { addWordToSpellCheckerDictionary: vi.fn() } - } - const browserWindowInstance = { - webContents, - on: vi.fn(), - isDestroyed: vi.fn(() => false), - isMaximized: vi.fn(() => true), - isFullScreen: vi.fn(() => false), - getSize: vi.fn(() => [1200, 800]), - setSize: vi.fn(), - maximize: vi.fn(), - show: vi.fn(), - loadFile: vi.fn(() => Promise.resolve()), - loadURL: vi.fn(() => Promise.resolve()) - } - browserWindowMock.mockImplementation(function () { - return browserWindowInstance - }) - - createMainWindow(null) + const { webContents, windowHandlers, browserWindowInstance } = openMainWindowForContextMenu() const tableTargetListener = vi .mocked(ipcMain.on) @@ -532,4 +537,38 @@ describe('createMainWindow', () => { ) expect(menuPopupMock).toHaveBeenCalledWith({ window: browserWindowInstance, x: 42, y: 84 }) }) + + it('builds the plain edit menu for the one right-click the renderer marks as plain text', () => { + const { webContents, windowHandlers } = openMainWindowForContextMenu() + const params = { + x: 10, + y: 20, + isEditable: true, + formControlType: 'none', + dictionarySuggestions: ['reference'], + misspelledWord: 'refrence' + } + const plainTextListener: ((...args: any[]) => void) | undefined = vi + .mocked(ipcMain.on) + .mock.calls.find(([channel]) => channel === 'editable-context-menu:plain-text-target')?.[1] + + plainTextListener?.({ sender: webContents }) + windowHandlers['context-menu']({}, params) + windowHandlers['context-menu']({}, params) + + expect(buildFromTemplateMock).toHaveBeenNthCalledWith(1, [ + expect.objectContaining({ label: 'reference' }), + { type: 'separator' }, + expect.objectContaining({ label: 'Add to dictionary' }), + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + expect.objectContaining({ label: 'Paste' }), + { role: 'selectAll' } + ]) + expect(buildFromTemplateMock).toHaveBeenNthCalledWith( + 2, + expect.arrayContaining([expect.objectContaining({ label: 'Format' })]) + ) + }) }) diff --git a/src/main/window/editable-context-menu.ts b/src/main/window/editable-context-menu.ts index c4e9b8cc596..3959535b84c 100644 --- a/src/main/window/editable-context-menu.ts +++ b/src/main/window/editable-context-menu.ts @@ -180,13 +180,17 @@ function buildMarkdownMenuTemplate( } function buildNativeEditMenuTemplate( + params: Electron.ContextMenuParams, webContents: EditableContextMenuWebContents ): Electron.MenuItemConstructorOptions[] { return [ { role: 'cut' }, { role: 'copy' }, editableContextPasteItem('Paste', webContents), - editableContextPasteItem('Paste as plain text', webContents, { plainTextOnly: true }), + // Why: a plain-text contenteditable (the chat composer) already pastes text literally and takes no text-only mode. + ...(params.formControlType !== 'none' + ? [editableContextPasteItem('Paste as plain text', webContents, { plainTextOnly: true })] + : []), { role: 'selectAll' } ] } @@ -194,14 +198,14 @@ function buildNativeEditMenuTemplate( export function buildEditableContextMenuTemplate( params: Electron.ContextMenuParams, webContents: EditableContextMenuWebContents, - options?: { tableTarget?: RichMarkdownContextMenuTableTarget | null } + options?: { tableTarget?: RichMarkdownContextMenuTableTarget | null; plainText?: boolean } ): Electron.MenuItemConstructorOptions[] { if (!params.isEditable) { return [] } const suggestions = params.dictionarySuggestions.slice(0, 5) - const isRichMarkdownSurface = params.formControlType === 'none' + const isRichMarkdownSurface = params.formControlType === 'none' && options?.plainText !== true const template: Electron.MenuItemConstructorOptions[] = suggestions.map((suggestion) => ({ label: suggestion, click: () => webContents.replaceMisspelling(suggestion) @@ -229,7 +233,7 @@ export function buildEditableContextMenuTemplate( { x: params.x, y: params.y }, options?.tableTarget ?? null ) - : buildNativeEditMenuTemplate(webContents)) + : buildNativeEditMenuTemplate(params, webContents)) ) return template diff --git a/src/main/window/main-window-focus-lifecycle.ts b/src/main/window/main-window-focus-lifecycle.ts index 5868d05d3c0..128afa7cb52 100644 --- a/src/main/window/main-window-focus-lifecycle.ts +++ b/src/main/window/main-window-focus-lifecycle.ts @@ -1,6 +1,7 @@ import { ipcMain, Menu, type BrowserWindow } from 'electron' import { isCrashReportReason } from '../../shared/crash-reporting' import { + plainTextContextMenuTargetChannel, richMarkdownContextMenuTargetChannel, type RichMarkdownContextMenuTableTarget } from '../../shared/rich-markdown-context-menu' @@ -104,14 +105,25 @@ export function installMainWindowFocusLifecycle(args: { pendingRichMarkdownContextMenuTableTarget = parseRichMarkdownContextMenuTableTarget(value) } ipcMain.on(richMarkdownContextMenuTargetChannel, onRichMarkdownContextMenuTarget) + // Why: the renderer sends this over the same frame IPC pipe just before Chromium reports the menu, so it pairs with the next one. + let pendingPlainTextContextMenuTarget = false + const onPlainTextContextMenuTarget = (event: Electron.IpcMainEvent): void => { + if (event.sender === mainWindow.webContents) { + pendingPlainTextContextMenuTarget = true + } + } + ipcMain.on(plainTextContextMenuTargetChannel, onPlainTextContextMenuTarget) const onMainContextMenu = (_event: Electron.Event, params: Electron.ContextMenuParams): void => { const tableTarget = matchingRichMarkdownContextMenuTableTarget( params, pendingRichMarkdownContextMenuTableTarget ) + const plainText = pendingPlainTextContextMenuTarget pendingRichMarkdownContextMenuTableTarget = null + pendingPlainTextContextMenuTarget = false const template = buildEditableContextMenuTemplate(params, mainWindow.webContents, { - tableTarget + tableTarget, + plainText }) if (template.length === 0 || mainWindow.isDestroyed()) { return @@ -125,6 +137,7 @@ export function installMainWindowFocusLifecycle(args: { const resetMarkdownEditorFocus = (): void => { markdownEditorFocused = false pendingRichMarkdownContextMenuTableTarget = null + pendingPlainTextContextMenuTarget = false } const resetTerminalInputFocus = (): void => { terminalInputFocused = false @@ -293,6 +306,7 @@ export function installMainWindowFocusLifecycle(args: { ipcMain.removeListener(floatingFocusChannel, onFloatingFocus) ipcMain.removeListener(shortcutRecorderFocusChannel, onShortcutRecorderFocused) ipcMain.removeListener(richMarkdownContextMenuTargetChannel, onRichMarkdownContextMenuTarget) + ipcMain.removeListener(plainTextContextMenuTargetChannel, onPlainTextContextMenuTarget) } return { dispose, diff --git a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts index c18f5b75654..50322c55b48 100644 --- a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -3,6 +3,7 @@ import { ipcRenderer, webFrame } from 'electron' import { richMarkdownContextMenuCommandChannel, richMarkdownContextMenuTargetChannel, + plainTextContextMenuTargetChannel, type RichMarkdownContextMenuCommandPayload, type RichMarkdownContextMenuTableTarget } from '../../shared/rich-markdown-context-menu' @@ -145,6 +146,9 @@ export const uiClipboardAndWindowControlsApi = { setRichMarkdownContextMenuTarget: (target: RichMarkdownContextMenuTableTarget | null): void => { ipcRenderer.send(richMarkdownContextMenuTargetChannel, target) }, + markPlainTextContextMenuTarget: (): void => { + ipcRenderer.send(plainTextContextMenuTargetChannel) + }, setTerminalInputFocused: (focused: boolean): void => { ipcRenderer.send('ui:setTerminalInputFocused', focused) }, diff --git a/src/preload/api/ui-window-api.ts b/src/preload/api/ui-window-api.ts index d6ea37268b0..184a767f1d6 100644 --- a/src/preload/api/ui-window-api.ts +++ b/src/preload/api/ui-window-api.ts @@ -53,6 +53,8 @@ export type UiWindowApi = { syncTrafficLights: (zoomFactor: number) => void setMarkdownEditorFocused: (focused: boolean) => void setRichMarkdownContextMenuTarget: (target: RichMarkdownContextMenuTableTarget | null) => void + /** The next editable context menu opens on plain text, not a rich Markdown editor. */ + markPlainTextContextMenuTarget: () => void setTerminalInputFocused: (focused: boolean) => void setFloatingFocus: (state: { panelFocused: boolean; terminalFocused: boolean }) => void setShortcutRecorderFocused: (focused: boolean) => void diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx index b4592e0894b..245086f68f6 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx @@ -137,6 +137,10 @@ function ImageHarness({

text