From 01172ffb9aa0e4d220445e6c6940ff13bf2ddd09 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 29 Aug 2026 03:55:03 -0700 Subject: [PATCH 01/10] fix(native-chat): preserve IME composition during streaming --- .../native-chat/NativeChatComposer.tsx | 1 + .../native-chat/NativeChatComposerField.tsx | 63 ++++- .../native-chat-composer-autogrow.test.tsx | 1 + .../native-chat-composer-composition.test.tsx | 216 ++++++++++++++++++ 4 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 256e7878f7a3..5205eb193832 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -411,6 +411,7 @@ export const NativeChatComposer = forwardRef (isComposingRef.current = false)} onPaste={handlePaste} pickerListboxId={picker.listboxId} onChoosePickerItem={completeItem} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index 7206b0288bd2..0e6be795c731 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -1,11 +1,14 @@ import type { ClipboardEventHandler, CompositionEventHandler, + FocusEventHandler, KeyboardEventHandler, RefObject } from 'react' +import { useLayoutEffect, useRef } from 'react' import { Image as ImageIcon, ImageOff, X } from 'lucide-react' import { translate } from '@/i18n/i18n' +import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event' import { cn } from '@/lib/utils' import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop' import { basename } from '@/lib/path' @@ -41,6 +44,7 @@ export type NativeChatComposerFieldProps = { onKeyDown: KeyboardEventHandler onCompositionStart: CompositionEventHandler onCompositionEnd: CompositionEventHandler + onBlur: FocusEventHandler onPaste: ClipboardEventHandler pickerListboxId: string onChoosePickerItem: (item: NativeChatPickerItem) => void @@ -84,6 +88,7 @@ export function NativeChatComposerField({ onKeyDown, onCompositionStart, onCompositionEnd, + onBlur, onPaste, pickerListboxId, onChoosePickerItem, @@ -100,6 +105,31 @@ export function NativeChatComposerField({ sessionOptionsSnapshot, sessionOptionsPickerRequest }: NativeChatComposerFieldProps): React.JSX.Element { + const imeEnterGesture = useImeEnterGestureOwnership() + const deferredDraftRef = useRef(null) + const lastDraftRef = useRef(draft) + + // Browser owns the provisional value; React synchronizes drafts only between IME sessions. + useLayoutEffect(() => { + const textarea = textareaRef.current + if (!textarea) { + return + } + if (imeEnterGesture.isComposing()) { + if (draft !== lastDraftRef.current) { + deferredDraftRef.current = textarea.value === draft ? null : draft + } + lastDraftRef.current = draft + return + } + deferredDraftRef.current = null + lastDraftRef.current = draft + if (textarea.value === draft) { + return + } + textarea.value = draft + }, [draft, imeEnterGesture, textareaRef]) + return (
{/* Extra bottom padding keeps the input box off the window rim. */} @@ -167,13 +197,38 @@ export function NativeChatComposerField({ ) : null}