mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(native-chat): show spelling suggestions when right-clicking chat inputs (#32035)
This commit is contained in:
@@ -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' })])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user