mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 08:02:38 +00:00
fix(chat): preserve browser IME composition ownership
This commit is contained in:
@@ -4,6 +4,7 @@ import type {
|
||||
KeyboardEventHandler,
|
||||
RefObject
|
||||
} from 'react'
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import { Image as ImageIcon, ImageOff, X } from 'lucide-react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -97,6 +98,17 @@ export function NativeChatComposerField({
|
||||
sessionOptionsSurface,
|
||||
sessionOptionsSnapshot
|
||||
}: NativeChatComposerFieldProps): React.JSX.Element {
|
||||
const compositionActiveRef = useRef(false)
|
||||
const pendingCompositionEnterRef = useRef(false)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea || compositionActiveRef.current || textarea.value === draft) {
|
||||
return
|
||||
}
|
||||
textarea.value = draft
|
||||
}, [draft, textareaRef])
|
||||
|
||||
return (
|
||||
<div className="shrink-0 bg-background">
|
||||
{/* Extra bottom padding keeps the input box off the window rim. */}
|
||||
@@ -164,13 +176,50 @@ export function NativeChatComposerField({
|
||||
) : null}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
defaultValue={draft}
|
||||
disabled={disabled}
|
||||
rows={2}
|
||||
onChange={(e) => onDraftChange(e.target.value, e.currentTarget)}
|
||||
onKeyDown={onKeyDown}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
onKeyDown={(event) => {
|
||||
const ownsCompositionEnter =
|
||||
event.nativeEvent.isComposing &&
|
||||
((event.key === 'Enter' && event.keyCode === 13) ||
|
||||
(event.key === 'Process' && event.keyCode === 229))
|
||||
if (ownsCompositionEnter) {
|
||||
pendingCompositionEnterRef.current = true
|
||||
return
|
||||
}
|
||||
if (
|
||||
pendingCompositionEnterRef.current &&
|
||||
event.key === 'Enter' &&
|
||||
event.keyCode === 13 &&
|
||||
!event.nativeEvent.isComposing
|
||||
) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
onKeyDown(event)
|
||||
}}
|
||||
onKeyUp={(event) => {
|
||||
if (
|
||||
(event.key === 'Enter' && event.keyCode === 13) ||
|
||||
(event.key === 'Process' && event.keyCode === 229)
|
||||
) {
|
||||
pendingCompositionEnterRef.current = false
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
compositionActiveRef.current = false
|
||||
pendingCompositionEnterRef.current = false
|
||||
}}
|
||||
onCompositionStart={(event) => {
|
||||
compositionActiveRef.current = true
|
||||
onCompositionStart(event)
|
||||
}}
|
||||
onCompositionEnd={(event) => {
|
||||
compositionActiveRef.current = false
|
||||
onCompositionEnd(event)
|
||||
}}
|
||||
onPaste={onPaste}
|
||||
onSelect={(e) => onTextareaSelect(e.currentTarget)}
|
||||
aria-expanded={autocomplete.mode === 'slash' || autocomplete.mode === 'skill'}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* has no layout engine, so real pixel growth is covered by app validation. */
|
||||
|
||||
import { createRef } from 'react'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
@@ -22,12 +22,20 @@ vi.mock('./NativeChatAutocompleteMenus', () => ({
|
||||
NativeChatPickerMenu: () => null
|
||||
}))
|
||||
|
||||
import { NativeChatComposerField } from './NativeChatComposerField'
|
||||
import {
|
||||
NativeChatComposerField,
|
||||
type NativeChatComposerFieldProps
|
||||
} from './NativeChatComposerField'
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
function renderField(draft: string): HTMLTextAreaElement {
|
||||
render(
|
||||
function composerField(
|
||||
draft: string,
|
||||
callbacks: Partial<
|
||||
Pick<NativeChatComposerFieldProps, 'onDraftChange' | 'onKeyDown' | 'onCompositionEnd'>
|
||||
> = {}
|
||||
): React.JSX.Element {
|
||||
return (
|
||||
<NativeChatComposerField
|
||||
textareaRef={createRef<HTMLTextAreaElement>()}
|
||||
draft={draft}
|
||||
@@ -44,11 +52,11 @@ function renderField(draft: string): HTMLTextAreaElement {
|
||||
dictationDisabled={false}
|
||||
isDictating={false}
|
||||
isDictationHoldMode={false}
|
||||
onDraftChange={vi.fn()}
|
||||
onDraftChange={callbacks.onDraftChange ?? vi.fn()}
|
||||
onTextareaSelect={vi.fn()}
|
||||
onKeyDown={vi.fn()}
|
||||
onKeyDown={callbacks.onKeyDown ?? vi.fn()}
|
||||
onCompositionStart={vi.fn()}
|
||||
onCompositionEnd={vi.fn()}
|
||||
onCompositionEnd={callbacks.onCompositionEnd ?? vi.fn()}
|
||||
onPaste={vi.fn()}
|
||||
pickerListboxId="picker"
|
||||
onChoosePickerItem={vi.fn()}
|
||||
@@ -64,6 +72,10 @@ function renderField(draft: string): HTMLTextAreaElement {
|
||||
sessionOptionsSnapshot={[]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function renderField(draft: string): HTMLTextAreaElement {
|
||||
render(composerField(draft))
|
||||
return screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
}
|
||||
|
||||
@@ -96,3 +108,97 @@ describe('native chat composer autogrow', () => {
|
||||
expect(renderField('a\n'.repeat(6)).style.height).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('native chat composer composition ownership', () => {
|
||||
it('preserves browser-owned Korean preedit through stale streaming rerenders', () => {
|
||||
const onCompositionEnd = vi.fn()
|
||||
const view = render(composerField('', { onCompositionEnd }))
|
||||
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
fireEvent.compositionStart(textarea)
|
||||
textarea.value = '가'
|
||||
|
||||
for (let index = 0; index < 120; index += 1) {
|
||||
view.rerender(composerField(`stale stream ${index}`, { onCompositionEnd }))
|
||||
expect(screen.getByRole('textbox')).toBe(textarea)
|
||||
expect(textarea.value).toBe('가')
|
||||
}
|
||||
|
||||
fireEvent.compositionEnd(textarea, { data: '가' })
|
||||
expect(onCompositionEnd).toHaveBeenCalledOnce()
|
||||
expect(onCompositionEnd.mock.calls[0][0].target.value).toBe('가')
|
||||
})
|
||||
|
||||
it('applies draft rerenders while ordinary English input is idle', () => {
|
||||
const view = render(composerField('abc'))
|
||||
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
|
||||
view.rerender(composerField('ordinary'))
|
||||
|
||||
expect(screen.getByRole('textbox')).toBe(textarea)
|
||||
expect(textarea.value).toBe('ordinary')
|
||||
})
|
||||
|
||||
it('owns an unmarked Enter redispatch until the marked IME gesture keyup', () => {
|
||||
const onKeyDown = vi.fn()
|
||||
render(composerField('가', { onKeyDown }))
|
||||
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
fireEvent.compositionStart(textarea)
|
||||
|
||||
const markedResult = fireEvent.keyDown(textarea, {
|
||||
key: 'Enter',
|
||||
keyCode: 13,
|
||||
isComposing: true
|
||||
})
|
||||
fireEvent.compositionEnd(textarea, { data: '가' })
|
||||
const redispatchResult = fireEvent.keyDown(textarea, {
|
||||
key: 'Enter',
|
||||
keyCode: 13,
|
||||
isComposing: false
|
||||
})
|
||||
|
||||
expect(markedResult).toBe(true)
|
||||
expect(redispatchResult).toBe(false)
|
||||
expect(onKeyDown).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.keyUp(textarea, { key: 'Enter', keyCode: 13 })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', keyCode: 13, isComposing: false })
|
||||
expect(onKeyDown).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retains the Windows Process gesture through its unmarked Enter redispatch', () => {
|
||||
const onKeyDown = vi.fn()
|
||||
render(composerField('가', { onKeyDown }))
|
||||
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
fireEvent.compositionStart(textarea)
|
||||
fireEvent.keyDown(textarea, {
|
||||
key: 'Process',
|
||||
keyCode: 229,
|
||||
isComposing: true
|
||||
})
|
||||
fireEvent.compositionEnd(textarea, { data: '가' })
|
||||
|
||||
const redispatchResult = fireEvent.keyDown(textarea, {
|
||||
key: 'Enter',
|
||||
keyCode: 13,
|
||||
isComposing: false
|
||||
})
|
||||
|
||||
expect(redispatchResult).toBe(false)
|
||||
expect(onKeyDown).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.keyUp(textarea, { key: 'Process', keyCode: 229 })
|
||||
fireEvent.keyUp(textarea, { key: 'Enter', keyCode: 13 })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', keyCode: 13, isComposing: false })
|
||||
expect(onKeyDown).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('passes ordinary English Enter through without an IME gesture', () => {
|
||||
const onKeyDown = vi.fn()
|
||||
render(composerField('abc', { onKeyDown }))
|
||||
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', keyCode: 13, isComposing: false })
|
||||
|
||||
expect(onKeyDown).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user