Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { useRef, useState } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'

import { isImeComposingKeyEvent } from './ime'

// No global setupFiles registers auto-cleanup, so unmount between tests —
// otherwise a second render() leaks the first editor and getByTestId('editor')
// matches multiple nodes.
Expand Down Expand Up @@ -78,6 +80,10 @@ function Harness({
}

const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (isImeComposingKeyEvent(event)) {
return
}

if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()

Expand Down Expand Up @@ -132,6 +138,21 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
expect(onSubmit).toHaveBeenCalledWith('hello world')
})

it('does not submit when Chromium reports IME Enter as keyCode 229', async () => {
const onSubmit = vi.fn()
const { getByTestId } = render(
<Harness onCancel={vi.fn()} onDrain={vi.fn()} onQueue={vi.fn()} onSubmit={onSubmit} />
)
const editor = getByTestId('editor')

await act(async () => {
editor.textContent = '你好'
fireEvent.keyDown(editor, { key: 'Enter', keyCode: 229 })
})

expect(onSubmit).not.toHaveBeenCalled()
})

it('queues a fast-typed message while busy instead of draining the queue or cancelling', async () => {
const onQueue = vi.fn()
const onDrain = vi.fn()
Expand Down
27 changes: 27 additions & 0 deletions apps/desktop/src/app/chat/composer/ime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
import { describe, expect, it } from 'vitest'

import { isImeComposingKeyEvent } from './ime'

function keyEvent({ isComposing = false, keyCode = 13 }: { isComposing?: boolean; keyCode?: number }) {
return {
nativeEvent: {
isComposing,
keyCode
}
} as ReactKeyboardEvent<HTMLElement>
}

describe('isImeComposingKeyEvent', () => {
it('detects standard composing key events', () => {
expect(isImeComposingKeyEvent(keyEvent({ isComposing: true }))).toBe(true)
})

it('detects Chromium IME processing keyCode 229 after isComposing flips false', () => {
expect(isImeComposingKeyEvent(keyEvent({ isComposing: false, keyCode: 229 }))).toBe(true)
})

it('does not treat normal Enter as IME composition', () => {
expect(isImeComposingKeyEvent(keyEvent({ isComposing: false, keyCode: 13 }))).toBe(false)
})
})
11 changes: 11 additions & 0 deletions apps/desktop/src/app/chat/composer/ime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'

const CHROMIUM_IME_PROCESSING_KEY_CODE = 229

export function isImeComposingKeyEvent(event: ReactKeyboardEvent<HTMLElement>): boolean {
// Chromium/Electron can report the IME confirmation key as keyCode 229
// even when isComposing is already false by the time React handles keydown.
// Treat it as composition so Enter confirms CJK preedit text instead of
// submitting the composer.
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === CHROMIUM_IME_PROCESSING_KEY_CODE
}
10 changes: 5 additions & 5 deletions apps/desktop/src/app/chat/composer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
} from './focus'
import { HelpHint } from './help-hint'
import { useAtCompletions } from './hooks/use-at-completions'
import { isImeComposingKeyEvent } from './ime'
import { useSlashCompletions } from './hooks/use-slash-completions'
import { useVoiceConversation } from './hooks/use-voice-conversation'
import { useVoiceRecorder } from './hooks/use-voice-recorder'
Expand Down Expand Up @@ -672,11 +673,10 @@ export function ChatBar({

const handleEditorKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
// IME composition: Enter confirms composed text, not a message submission.
// We check both composingRef (set by compositionstart/compositionend, robust
// across browsers) and nativeEvent.isComposing (Chromium fallback). Without
// this guard, pressing Enter to finalise a Korean/Japanese/Chinese IME
// preedit fires submitDraft() and splits the message mid-word.
if (composingRef.current || event.nativeEvent.isComposing) {
// Check React's composition ref, native isComposing, and Chromium's
// IME-processing keyCode 229 fallback. Some Electron/macOS/CJK paths flip
// isComposing false before the Enter keydown reaches React.
if (composingRef.current || isImeComposingKeyEvent(event)) {
return
}

Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/components/assistant-ui/thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
onComposerInsertRequest
} from '@/app/chat/composer/focus'
import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions'
import { isImeComposingKeyEvent } from '@/app/chat/composer/ime'
import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions'
import {
dragHasAttachments,
Expand Down Expand Up @@ -1401,6 +1402,12 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
)

const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
// IME Enter confirms the composed text in the edit composer. Do not let it
// select trigger items or submit the edited message.
if (isImeComposingKeyEvent(event)) {
return
}

if (trigger && triggerItems.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
Expand Down