@@ -1892,6 +1892,13 @@ export const ChatInput = ({
18921892 // Keep inputValue in a ref so handleCursorChange can read it without re-binding.
18931893 const inputValueRef = useRef ( inputValue ) ;
18941894 inputValueRef . current = inputValue ;
1895+ // Mobile Safari emits controlled-textarea changes while an IME composition is still in
1896+ // progress. Treating each provisional value as an ordinary edit can make its occasionally stale
1897+ // selection range look as though the edit crossed a connection capsule, which removes the
1898+ // capsule. Keep rendering the provisional text, but defer token bookkeeping until composition
1899+ // ends and compare the committed value with the value from before composition began.
1900+ const isComposingRef = useRef ( false ) ;
1901+ const compositionStartValueRef = useRef ( "" ) ;
18951902
18961903 // Seed the composer from an external suggestion (Home task cards). Re-runs whenever the nonce
18971904 // changes so picking the same suggestion twice still works. Focus + move the cursor to the end.
@@ -3089,13 +3096,31 @@ export const ChatInput = ({
30893096 aria-controls = { slashCommandPicker . open ? slashCommandPicker . listboxId : undefined }
30903097 aria-activedescendant = { slashCommandPicker . activeDescendant }
30913098 onChange = { ( e ) => {
3092- handleInputChange ( e . target . value , e . target . selectionStart ?? 0 ) ;
3099+ if ( isComposingRef . current ) {
3100+ setInputValue ( e . target . value ) ;
3101+ } else {
3102+ handleInputChange ( e . target . value , e . target . selectionStart ?? 0 ) ;
3103+ }
30933104 syncPickerCaret ( e . target . selectionStart ?? 0 ) ;
30943105 requestAnimationFrame ( handleCursorChange ) ;
30953106 // Auto-resize after value change
30963107 autoResizeTextarea ( e . target , minRows , newChat ? 10 : 4 ) ;
30973108 syncMirrorScroll ( e . target ) ;
30983109 } }
3110+ onCompositionStart = { ( ) => {
3111+ isComposingRef . current = true ;
3112+ compositionStartValueRef . current = inputValueRef . current ;
3113+ } }
3114+ onCompositionEnd = { ( e ) => {
3115+ isComposingRef . current = false ;
3116+ // handleInputChange deliberately reads the old value through inputValueRef. React
3117+ // state has tracked provisional composition text, so restore only the ref long
3118+ // enough to calculate the complete committed edit against the pre-IME value.
3119+ inputValueRef . current = compositionStartValueRef . current ;
3120+ handleInputChange ( e . currentTarget . value , e . currentTarget . selectionStart ?? 0 ) ;
3121+ syncPickerCaret ( e . currentTarget . selectionStart ?? 0 ) ;
3122+ requestAnimationFrame ( handleCursorChange ) ;
3123+ } }
30993124 onSelect = { handleCursorChange }
31003125 onClick = { handleCursorChange }
31013126 onKeyUp = { handleCursorChange }
@@ -3146,6 +3171,9 @@ export const ChatInput = ({
31463171 }
31473172 } }
31483173 onKeyDown = { ( e ) => {
3174+ // Enter confirms Japanese input on iOS. It must not select a slash command or send
3175+ // the message while the browser still considers the keypress part of composition.
3176+ if ( e . nativeEvent . isComposing || isComposingRef . current ) return ;
31493177 if ( slashCommandPicker . open && e . key === "Escape" ) {
31503178 e . preventDefault ( ) ;
31513179 slashCommandPicker . dismiss ( ) ;
0 commit comments