diff --git a/src/web-ui/src/app/scenes/profile/views/AssistantQuickInput.tsx b/src/web-ui/src/app/scenes/profile/views/AssistantQuickInput.tsx index 61606253d5..3c0216074a 100644 --- a/src/web-ui/src/app/scenes/profile/views/AssistantQuickInput.tsx +++ b/src/web-ui/src/app/scenes/profile/views/AssistantQuickInput.tsx @@ -16,7 +16,7 @@ import { IconButton, Textarea } from '@/component-library'; import { ModelSelector } from '@/flow_chat/components/ModelSelector'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; import { openMainSession } from '@/flow_chat/services/sessionActivation'; -import { useImeEnterGuard } from '@/flow_chat/hooks/useImeEnterGuard'; +import { useImeOwnedKeyGuard } from '@/flow_chat/hooks/useImeOwnedKeyGuard'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { notificationService } from '@/shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; @@ -39,7 +39,7 @@ const AssistantQuickInput: React.FC = ({ const { setActiveWorkspace } = useWorkspaceContext(); const [value, setValue] = useState(''); const [sending, setSending] = useState(false); - const { isImeEnter, handleCompositionStart, handleCompositionEnd } = useImeEnterGuard(); + const { isImeOwnedKey, handleCompositionStart, handleCompositionEnd } = useImeOwnedKeyGuard(); const handleChange = useCallback((e: React.ChangeEvent) => { setValue(e.target.value); @@ -81,11 +81,11 @@ const AssistantQuickInput: React.FC = ({ const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { - if (isImeEnter(e)) return; + if (isImeOwnedKey(e)) return; e.preventDefault(); void handleSend(); } - }, [handleSend, isImeEnter]); + }, [handleSend, isImeOwnedKey]); const placeholder = assistantName ? t('input.assistantPlaceholder', { name: assistantName }) diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.test.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.test.tsx index b20c1368d3..d19ee1ce4c 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.test.tsx @@ -1,6 +1,7 @@ import React, { act } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createRoot, type Root } from 'react-dom/client'; +import { Simulate } from 'react-dom/test-utils'; import { JSDOM } from 'jsdom'; import { UserMessageEditComposer } from './UserMessageEditComposer'; import type { ComposerPresentation } from '../../utils/composerPresentation'; @@ -36,6 +37,47 @@ describe('UserMessageEditComposer', () => { let container: HTMLDivElement; let root: Root; + const renderComposer = async (options: { rich?: boolean } = {}) => { + const onChange = vi.fn(); + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + + await act(async () => { + root.render( + , + ); + }); + + return { onChange, onSubmit, onCancel }; + }; + + const dispatchKey = async ( + target: Element, + key: string, + init: KeyboardEventInit = {}, + ) => { + const event = new dom.window.KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + ...init, + }); + await act(async () => { + target.dispatchEvent(event); + }); + return event; + }; + beforeEach(() => { dom = new JSDOM('
', { pretendToBeVisual: true, @@ -72,6 +114,92 @@ describe('UserMessageEditComposer', () => { vi.unstubAllGlobals(); }); + it('keeps Enter with the IME during tracked composition and submits afterward', async () => { + const { onSubmit } = await renderComposer(); + const textarea = container.querySelector('textarea'); + expect(textarea).toBeTruthy(); + + await act(async () => { + Simulate.compositionStart(textarea!); + }); + const imeEnter = await dispatchKey(textarea!, 'Enter'); + + expect(imeEnter.defaultPrevented).toBe(false); + expect(onSubmit).not.toHaveBeenCalled(); + + await act(async () => { + Simulate.compositionEnd(textarea!); + }); + const submitEnter = await dispatchKey(textarea!, 'Enter'); + + expect(submitEnter.defaultPrevented).toBe(true); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['native isComposing', { isComposing: true }], + ['native keyCode 229', { keyCode: 229 }], + ] as const)('keeps Enter with the IME for %s', async (_label, init) => { + const { onSubmit } = await renderComposer(); + const textarea = container.querySelector('textarea'); + expect(textarea).toBeTruthy(); + + const event = await dispatchKey(textarea!, 'Enter', init); + + expect(event.defaultPrevented).toBe(false); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('keeps Escape with the IME during tracked composition and cancels afterward', async () => { + const { onCancel } = await renderComposer(); + const textarea = container.querySelector('textarea'); + expect(textarea).toBeTruthy(); + + await act(async () => { + Simulate.compositionStart(textarea!); + }); + const imeEscape = await dispatchKey(textarea!, 'Escape'); + + expect(imeEscape.defaultPrevented).toBe(false); + expect(onCancel).not.toHaveBeenCalled(); + + await act(async () => { + Simulate.compositionEnd(textarea!); + }); + const cancelEscape = await dispatchKey(textarea!, 'Escape'); + + expect(cancelEscape.defaultPrevented).toBe(true); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['native isComposing', { isComposing: true }], + ['native keyCode 229', { keyCode: 229 }], + ] as const)('keeps Escape with the IME for %s', async (_label, init) => { + const { onCancel } = await renderComposer(); + const textarea = container.querySelector('textarea'); + expect(textarea).toBeTruthy(); + + const event = await dispatchKey(textarea!, 'Escape', init); + + expect(event.defaultPrevented).toBe(false); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it('keeps rich editor Enter and Escape handling inside its IME boundary', async () => { + const { onSubmit, onCancel } = await renderComposer({ rich: true }); + const editor = container.querySelector('.rich-text-input'); + expect(editor).toBeTruthy(); + + const enter = await dispatchKey(editor!, 'Enter', { keyCode: 229 }); + const escape = await dispatchKey(editor!, 'Escape', { keyCode: 229 }); + + expect(enter.defaultPrevented).toBe(false); + expect(escape.defaultPrevented).toBe(false); + expect(onSubmit).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); + }); + it('restores and removes reference capsules atomically', async () => { const onChange = vi.fn(); const onSubmit = vi.fn(); diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.tsx index 4230bc2d4b..92edda87be 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Check, Loader2, X } from 'lucide-react'; import { Textarea } from '@/component-library'; +import { useImeOwnedKeyGuard } from '@/flow_chat/hooks/useImeOwnedKeyGuard'; import type { ContextItem } from '@/shared/types/context'; import { FileMentionPicker } from '../FileMentionPicker'; import { @@ -190,6 +191,7 @@ export const UserMessageEditComposer: React.FC = ( excludeSessionId, }) => { const textareaRef = useRef(null); + const { isImeOwnedKey, handleCompositionStart, handleCompositionEnd } = useImeOwnedKeyGuard(); const trimmedValue = value.trim(); const canSubmit = trimmedValue.length > 0 && !isSubmitting; @@ -207,6 +209,10 @@ export const UserMessageEditComposer: React.FC = ( }, [canSubmit, onSubmit]); const handleKeyDown = useCallback((event: React.KeyboardEvent) => { + if ((event.key === 'Enter' || event.key === 'Escape') && isImeOwnedKey(event)) { + return; + } + if (event.key === 'Escape') { event.preventDefault(); onCancel(); @@ -217,7 +223,7 @@ export const UserMessageEditComposer: React.FC = ( event.preventDefault(); handleSubmit(); } - }, [handleSubmit, onCancel]); + }, [handleSubmit, isImeOwnedKey, onCancel]); if (presentation) { return ( @@ -248,6 +254,8 @@ export const UserMessageEditComposer: React.FC = ( value={value} onChange={(event) => onChange(event.target.value)} onKeyDown={handleKeyDown} + onCompositionStart={handleCompositionStart} + onCompositionEnd={handleCompositionEnd} placeholder={placeholder} autoResize disabled={isSubmitting} diff --git a/src/web-ui/src/flow_chat/hooks/index.ts b/src/web-ui/src/flow_chat/hooks/index.ts index dbc7c9d001..da2d288bdd 100644 --- a/src/web-ui/src/flow_chat/hooks/index.ts +++ b/src/web-ui/src/flow_chat/hooks/index.ts @@ -2,6 +2,5 @@ export { useFlowChat } from './useFlowChat'; export { useActiveSessionState } from './useActiveSessionState'; export { useAutoScroll } from './useAutoScroll'; export { useTypewriter } from './useTypewriter'; -export { useImeEnterGuard } from './useImeEnterGuard'; -export type { ImeEnterGuard } from './useImeEnterGuard'; - +export { useImeOwnedKeyGuard } from './useImeOwnedKeyGuard'; +export type { ImeOwnedKeyGuard } from './useImeOwnedKeyGuard'; diff --git a/src/web-ui/src/flow_chat/hooks/useImeEnterGuard.ts b/src/web-ui/src/flow_chat/hooks/useImeEnterGuard.ts deleted file mode 100644 index eb60540211..0000000000 --- a/src/web-ui/src/flow_chat/hooks/useImeEnterGuard.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * useImeEnterGuard — IME-safe Enter detection for chat-style inputs. - * - * Problem: with Chinese / Japanese / Korean IMEs, the Enter key that - * confirms a candidate must NOT be treated as "send message", but the - * Enter key that actually submits the input MUST trigger send — and - * fast typists may chain "confirm candidate → send" within a few - * milliseconds. - * - * Strategy (no time-based heuristics): - * 1. Track our own "is composing" flag via composition events. This - * handles browsers/IMEs where `KeyboardEvent.isComposing` is - * occasionally unreliable (notably some Safari / Linux paths). - * 2. Treat any Enter `keydown` whose `keyCode === 229` as IME-owned. - * `keyCode 229` is the W3C-defined "composition keyCode" that - * every major browser still emits while an IME is processing the - * key, even when `isComposing` has already flipped back to false. - * - * The combination removes the need for a fragile time window guard - * (which would otherwise swallow legitimate fast Enter presses) while - * still rejecting the IME-confirmation Enter on every platform we - * tested. - * - * Reference behaviour mirrors how Slack / Discord / Lark handle the - * same race condition. - */ - -import { useCallback, useRef } from 'react'; - -export interface ImeEnterGuard { - isImeEnter: (e: React.KeyboardEvent) => boolean; - handleCompositionStart: () => void; - handleCompositionEnd: () => void; -} - -export function useImeEnterGuard(): ImeEnterGuard { - const isImeComposingRef = useRef(false); - - const handleCompositionStart = useCallback(() => { - isImeComposingRef.current = true; - }, []); - - const handleCompositionEnd = useCallback(() => { - isImeComposingRef.current = false; - }, []); - - const isImeEnter = useCallback((e: React.KeyboardEvent) => { - const native = e.nativeEvent as KeyboardEvent | undefined; - if (isImeComposingRef.current) return true; - if (native?.isComposing) return true; - // `keyCode === 229` is the canonical IME "in-flight" signal and is - // still emitted by every evergreen browser even though the field is - // marked legacy. It catches the race where the IME swallows Enter - // to confirm a candidate but `isComposing` has already cleared. - if (native?.keyCode === 229) return true; - return false; - }, []); - - return { isImeEnter, handleCompositionStart, handleCompositionEnd }; -} diff --git a/src/web-ui/src/flow_chat/hooks/useImeOwnedKeyGuard.ts b/src/web-ui/src/flow_chat/hooks/useImeOwnedKeyGuard.ts new file mode 100644 index 0000000000..de12eb0ce1 --- /dev/null +++ b/src/web-ui/src/flow_chat/hooks/useImeOwnedKeyGuard.ts @@ -0,0 +1,36 @@ +/** + * IME ownership detection for keyboard shortcuts on text inputs. + * + * Composition lifecycle tracking covers browsers where the native keyboard + * event is incomplete. The native signals cover event-ordering races where + * composition has ended locally but the IME still owns the key. + */ + +import { useCallback, useRef } from 'react'; + +export interface ImeOwnedKeyGuard { + isImeOwnedKey: (event: React.KeyboardEvent) => boolean; + handleCompositionStart: () => void; + handleCompositionEnd: () => void; +} + +export function useImeOwnedKeyGuard(): ImeOwnedKeyGuard { + const isImeComposingRef = useRef(false); + + const handleCompositionStart = useCallback(() => { + isImeComposingRef.current = true; + }, []); + + const handleCompositionEnd = useCallback(() => { + isImeComposingRef.current = false; + }, []); + + const isImeOwnedKey = useCallback((event: React.KeyboardEvent) => { + const nativeEvent = event.nativeEvent as KeyboardEvent | undefined; + return isImeComposingRef.current + || nativeEvent?.isComposing === true + || nativeEvent?.keyCode === 229; + }, []); + + return { isImeOwnedKey, handleCompositionStart, handleCompositionEnd }; +}