diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index cbb4e972..caa56aa9 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -96,6 +96,7 @@ import { type ComposerEditorHandle, } from './composer/editor/ComposerEditor'; import { createComposerEditorViewStore } from './composer/editor/viewStore'; +import { composerAutoCorrect } from './composer/editor/autocorrect'; import { appendInlineText, appendWithLineBreaks, @@ -2624,7 +2625,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo : t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat') : t('chat.chatInput.placeholder.selectSession')} editable={Boolean(currentSessionId || newSessionDraftOpen)} - autoCorrect={isMobile} + autoCorrect={composerAutoCorrect({ isMobile })} autoCapitalize={isMobile ? 'sentences' : 'none'} spellCheck={isMobile || inputSpellcheckEnabled} fillContainer={isComposerExpanded} diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index 3544932a..75536165 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -76,6 +76,14 @@ token: themes define `--interactive-selection` with its own alpha, so a translucent mix of it is nearly invisible. +The content element keeps the existing correction policy: on in the mobile UI, +off elsewhere. CodeMirror also reads the attribute and reverts Apple and +Android's insert-period-on-double-space only when its value is exactly `off`. +`editor/autocorrect.ts` uses the HTML standard's +[ASCII case-insensitive `autocorrect` keywords](https://html.spec.whatwg.org/multipage/interaction.html#attr-autocorrect) +to keep desktop word correction off while avoiding that CodeMirror-only +revert. Its platform checks deliberately match CodeMirror's own browser flags. + `composerLanguage.ts` retokenizes the whole document on every change. The composer holds a prompt, not a source file: it is short enough that a full pass is cheaper and far simpler than incremental mapping, and it keeps the editor diff --git a/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx b/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx index f3715dd7..0dde5121 100644 --- a/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx +++ b/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx @@ -34,6 +34,7 @@ import { import { cn } from '@/lib/utils'; import type { ComposerLanguageContext } from '../language/tokenize'; +import type { ComposerAutoCorrect } from './autocorrect'; import { composerLanguage, setLanguageContext } from './composerLanguage'; import type { ComposerEditorViewStore } from './viewStore'; import { composerEditorTheme, composerNativeSelectionExtension } from './theme'; @@ -89,8 +90,11 @@ export interface ComposerEditorProps { placeholder?: string; editable?: boolean; spellCheck?: boolean; - /** Mobile keyboards; ignored on desktop. */ - autoCorrect?: boolean; + /** + * The content element's autocorrect keyword. See `autocorrect.ts` for the + * case-sensitive CodeMirror workaround. + */ + autoCorrect?: ComposerAutoCorrect; autoCapitalize?: 'none' | 'sentences'; /** Fill the available height instead of growing with the content. */ fillContainer?: boolean; @@ -147,7 +151,7 @@ export const ComposerEditor = React.forwardRef): Navigator => ({ + maxTouchPoints: 0, + platform: '', + userAgent: '', + vendor: '', + ...overrides, +} as Navigator); + +const codeMirrorKeepsDoubleSpacePeriod = ( + autoCorrect: ComposerAutoCorrect, +): boolean => autoCorrect !== 'off'; + +const affectedPlatforms: Array<[string, Navigator]> = [ + ['macOS', platform({ platform: 'MacIntel' })], + ['iPhone', platform({ + platform: 'iPhone', + userAgent: 'Mozilla/5.0 Mobile/15E148 Safari/604.1', + vendor: 'Apple Computer, Inc.', + })], + ['iPadOS touch detection', platform({ + maxTouchPoints: 5, + userAgent: 'Mozilla/5.0 Version/17.4 Safari/605.1.15', + vendor: 'Apple Computer, Inc.', + })], + ['Android', platform({ + platform: 'Linux armv8l', + userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8)', + })], +]; + +const unaffectedPlatforms: Array<[string, Navigator]> = [ + ['Windows', platform({ platform: 'Win32' })], + ['Linux', platform({ platform: 'Linux x86_64' })], +]; + +describe('composerAutoCorrect', () => { + test('matches the pinned CodeMirror period-revert guard', () => { + const source = readFileSync( + fileURLToPath(import.meta.resolve('@codemirror/view')), + 'utf8', + ); + const semantics = source + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\s+/g, ''); + + expect(/getAttribute\(["']autocorrect["']\)==["']off["']/.test(semantics)).toBe(true); + expect(semantics).toContain( + 'constios=safari&&(/Mobile\\/\\w+/.test(nav.userAgent)||nav.maxTouchPoints>2)', + ); + expect(semantics).toContain('mac:ios||/Mac/.test(nav.platform)'); + expect(semantics).toContain('android:/Android\\b/.test(nav.userAgent)'); + }); + + for (const [name, navigator] of affectedPlatforms) { + test(`preserves the ${name} platform period without enabling autocorrect`, () => { + const autoCorrect = composerAutoCorrect({ isMobile: false, navigator }); + + expect(autoCorrect.toLowerCase()).toBe('off'); + // @codemirror/view 6.39.13 reverts the native period only for exact "off". + expect(codeMirrorKeepsDoubleSpacePeriod(autoCorrect)).toBe(true); + }); + } + + for (const [name, navigator] of unaffectedPlatforms) { + test(`leaves desktop correction off on ${name}`, () => { + expect(composerAutoCorrect({ isMobile: false, navigator })).toBe('off'); + }); + } + + test('uses CodeMirror platform detection rather than a macOS user agent', () => { + expect(composerAutoCorrect({ + isMobile: false, + navigator: platform({ + platform: 'Linux x86_64', + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + }), + })).toBe('off'); + }); + + test('preserves the existing mobile autocorrect policy', () => { + expect(composerAutoCorrect({ + isMobile: true, + navigator: platform({ platform: 'Win32' }), + })).toBe('on'); + }); +}); diff --git a/packages/ui/src/components/chat/composer/editor/autocorrect.ts b/packages/ui/src/components/chat/composer/editor/autocorrect.ts new file mode 100644 index 00000000..7437407f --- /dev/null +++ b/packages/ui/src/components/chat/composer/editor/autocorrect.ts @@ -0,0 +1,24 @@ +export type ComposerAutoCorrect = 'on' | 'off' | 'Off'; + +type PlatformNavigator = Pick; + +/** Keep desktop autocorrect off without triggering CodeMirror's period revert. */ +export function composerAutoCorrect(options: { + isMobile: boolean; + navigator?: PlatformNavigator; +}): ComposerAutoCorrect { + if (options.isMobile) return 'on'; + + const nav = options.navigator + ?? (typeof navigator === 'undefined' + ? { maxTouchPoints: 0, platform: '', userAgent: '', vendor: '' } + : navigator); + // These must match CodeMirror's flags because its revert checks exact "off". + const ios = /Apple Computer/.test(nav.vendor) + && (/Mobile\/\w+/.test(nav.userAgent) || nav.maxTouchPoints > 2); + return ios || /Mac/.test(nav.platform) || /Android\b/.test(nav.userAgent) + ? 'Off' + : 'off'; +}