fix(native-chat): show spelling suggestions when right-clicking chat inputs (#32035)

This commit is contained in:
Kelvin Amoaba
2026-10-22 21:55:57 +00:00
committed by GitHub
parent 6a6c9e518d
commit 44ab202965
9 changed files with 148 additions and 56 deletions
@@ -21,6 +21,44 @@ import {
resetMainWindowMocks
} from './createMainWindow-test-harness'
function openMainWindowForContextMenu() {
const windowHandlers: Record<string, (...args: any[]) => 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<string, (...args: any[]) => 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' })])
)
})
})
+8 -4
View File
@@ -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
+15 -1
View File
@@ -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,
@@ -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)
},
+2
View File
@@ -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
@@ -137,6 +137,10 @@ function ImageHarness({
</button>
<p>text</p>
<textarea aria-label="composer" />
<div role="textbox" aria-label="prompt" contentEditable suppressContentEditableWarning>
typo
<span contentEditable={false}>pill</span>
</div>
{menu}
</div>
)
@@ -223,12 +227,28 @@ describe('useNativeChatContextMenu', () => {
expect(copyTextItem()).toBeUndefined()
})
it('offers Paste in a text field but not on the transcript', () => {
it.each(['composer', 'prompt'])(
'leaves a right-click in the %s field to the native menu with spelling suggestions',
(name) => {
const markPlainTextContextMenuTarget = vi.fn()
vi.stubGlobal('api', { ui: { markPlainTextContextMenuTarget } })
render(<ImageHarness />)
const field = screen.getByRole('textbox', { name })
// fireEvent returns false when the handler prevented the default.
expect(fireEvent.pointerDown(field, { button: 2 })).toBe(true)
expect(fireEvent.contextMenu(field)).toBe(true)
expect(markPlainTextContextMenuTarget).toHaveBeenCalledOnce()
expect(pasteItem()).toBeUndefined()
}
)
it('keeps the chat menu on the transcript and on a pill inside the prompt', () => {
render(<ImageHarness />)
fireEvent.contextMenu(screen.getByText('text'))
expect(fireEvent.contextMenu(screen.getByText('text'))).toBe(false)
expect(pasteItem()).toBeUndefined()
fireEvent.contextMenu(screen.getByRole('textbox', { name: 'composer' }))
expect(fireEvent.contextMenu(screen.getByText('pill'))).toBe(false)
expect(pasteItem()).toBeDefined()
})
@@ -26,10 +26,7 @@ import { isMacPlatform, nativeChatToggleShortcutLabel } from './native-chat-shor
import { TabWorkspaceLayoutMenuSection } from '@/components/tab-bar/TabWorkspaceLayoutMenuSection'
import { canMoveTabToNewPaneColumn } from '@/components/tab-bar/tab-move-to-pane-column'
import { isEditableTarget } from '@/lib/editable-target'
import {
findNativeChatAnswerField,
findNativeChatPasteField
} from '@/lib/native-chat-paste-request'
import { findEditablePrimarySelectionPasteTarget } from '@/lib/primary-selection-paste'
import { NativeChatCopyOrcaSessionIdMenuItem } from './NativeChatCopyOrcaSessionIdMenuItem'
import { NativeChatSelectionQuote } from './NativeChatSelectionQuote'
import { NativeChatPaneMenuItems } from './NativeChatPaneMenuItems'
@@ -55,7 +52,6 @@ type NativeChatContextMenuState = {
point: { x: number; y: number }
selectedText: string
canPaste: boolean
pasteOrigin: HTMLTextAreaElement | HTMLInputElement | null
image?: NativeChatCopyImage
target: NativeChatContextMenuTarget
returnFocusTo: Element | null
@@ -91,7 +87,7 @@ export type NativeChatContextMenuTargetArgs = {
}
export type NativeChatContextMenuActions = {
onPaste: (origin?: HTMLTextAreaElement | HTMLInputElement) => void
onPaste: () => void
onSplitRight: () => void
onSplitDown: () => void
canEqualizePaneSizes: boolean
@@ -163,7 +159,6 @@ export function useNativeChatContextMenu({
point: { x: 0, y: 0 },
selectedText: '',
canPaste: false,
pasteOrigin: null,
target: noNativeChatContextMenuTarget,
returnFocusTo: null
})
@@ -194,8 +189,15 @@ export function useNativeChatContextMenu({
const onContextMenuCapture = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
event.preventDefault()
event.stopPropagation()
if (opensNativeEditableMenu(event.target)) {
// A cancelled event opens no native menu that could consume the hint.
if (!event.defaultPrevented) {
window.api.ui.markPlainTextContextMenuTarget()
}
return
}
event.preventDefault()
if (!enabled) {
return
}
@@ -205,9 +207,6 @@ export function useNativeChatContextMenu({
point: { x: event.clientX, y: event.clientY },
selectedText: getNativeChatSelectedText(rootRef.current),
canPaste: isEditableTarget(event.target),
pasteOrigin:
findNativeChatPasteField(event.target, rootRef.current) ??
findNativeChatAnswerField(event.target, rootRef.current),
returnFocusTo: rootRef.current?.ownerDocument.activeElement ?? null,
image: readNativeChatCopyImage(event.target),
target: readNativeChatContextMenuTarget(event.target, {
@@ -258,7 +257,6 @@ export function useNativeChatContextMenu({
setState((prev) => ({
...prev,
image: undefined,
pasteOrigin: null,
target: noNativeChatContextMenuTarget,
returnFocusTo: null
}))
@@ -285,7 +283,7 @@ export function useNativeChatContextMenu({
rewind={rewind}
/>
{state.canPaste ? (
<DropdownMenuItem onSelect={() => actions.onPaste(state.pasteOrigin ?? undefined)}>
<DropdownMenuItem onSelect={() => actions.onPaste()}>
<Clipboard />
{translate('auto.components.terminal.pane.TerminalContextMenu.0a917b591a', 'Paste')}
</DropdownMenuItem>
@@ -345,11 +343,19 @@ export function useNativeChatContextMenu({
function keepSelectionThroughMenuPress(event: React.PointerEvent<HTMLElement>): void {
// Why: left alone, the press that opens the menu collapses the selection before contextmenu fires.
if (event.button === 2 || (isMacPlatform() && event.ctrlKey)) {
if (
(event.button === 2 || (isMacPlatform() && event.ctrlKey)) &&
!opensNativeEditableMenu(event.target)
) {
event.preventDefault()
}
}
// Why: only Chromium's own menu for a typing target carries the OS spellchecker's suggestions.
function opensNativeEditableMenu(target: EventTarget | null): boolean {
return findEditablePrimarySelectionPasteTarget(target) !== null
}
function getNativeChatSelectedText(root: HTMLElement | null): string {
const selection = window.getSelection()
if (!root || !selection || selection.isCollapsed) {
@@ -296,6 +296,7 @@ export function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
syncTrafficLights: () => {},
setMarkdownEditorFocused: () => {},
setRichMarkdownContextMenuTarget: () => {},
markPlainTextContextMenuTarget: () => {},
setTerminalInputFocused: () => {},
setFloatingFocus: () => {},
setShortcutRecorderFocused: () => {},
+2
View File
@@ -41,3 +41,5 @@ export type RichMarkdownContextMenuTableTarget = {
export const richMarkdownContextMenuCommandChannel = 'rich-markdown:context-command'
export const richMarkdownContextMenuTargetChannel = 'rich-markdown:context-target'
// A contenteditable that is not a rich Markdown editor, such as the chat composer.
export const plainTextContextMenuTargetChannel = 'editable-context-menu:plain-text-target'