Merge pull request #2510 from IbrahimKhan12/fix/2504-composer-double-space-period

fix: keep the composer's autocorrect on where the platform provides one
This commit is contained in:
Bohdan Triapitsyn
2026-08-28 01:22:16 +03:00
committed by GitHub
5 changed files with 135 additions and 6 deletions
@@ -104,6 +104,7 @@ import {
type ComposerEditorHandle, type ComposerEditorHandle,
} from './composer/editor/ComposerEditor'; } from './composer/editor/ComposerEditor';
import { createComposerEditorViewStore } from './composer/editor/viewStore'; import { createComposerEditorViewStore } from './composer/editor/viewStore';
import { composerAutoCorrect } from './composer/editor/autocorrect';
import { import {
appendInlineText, appendInlineText,
appendWithLineBreaks, appendWithLineBreaks,
@@ -2852,7 +2853,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
: t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat') : t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat')
: t('chat.chatInput.placeholder.selectSession')} : t('chat.chatInput.placeholder.selectSession')}
editable={Boolean(currentSessionId || newSessionDraftOpen)} editable={Boolean(currentSessionId || newSessionDraftOpen)}
autoCorrect={isMobile} autoCorrect={composerAutoCorrect({ isMobile })}
autoCapitalize={isMobile ? 'sentences' : 'none'} autoCapitalize={isMobile ? 'sentences' : 'none'}
spellCheck={isMobile || inputSpellcheckEnabled} spellCheck={isMobile || inputSpellcheckEnabled}
fillContainer={isComposerExpanded} fillContainer={isComposerExpanded}
@@ -112,6 +112,14 @@ token: themes define `--interactive-selection` with its own alpha, so mixing it
with transparent again is nearly invisible. The iOS system overlay owns its with transparent again is nearly invisible. The iOS system overlay owns its
visible selection fill. visible selection fill.
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 `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 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 is cheaper and far simpler than incremental mapping, and it keeps the editor
@@ -34,6 +34,7 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { ComposerLanguageContext } from '../language/tokenize'; import type { ComposerLanguageContext } from '../language/tokenize';
import type { ComposerAutoCorrect } from './autocorrect';
import { composerLanguage, setLanguageContext } from './composerLanguage'; import { composerLanguage, setLanguageContext } from './composerLanguage';
import type { ComposerEditorViewStore } from './viewStore'; import type { ComposerEditorViewStore } from './viewStore';
import { composerEditorTheme, composerSelectionExtension } from './theme'; import { composerEditorTheme, composerSelectionExtension } from './theme';
@@ -89,8 +90,11 @@ export interface ComposerEditorProps {
placeholder?: string; placeholder?: string;
editable?: boolean; editable?: boolean;
spellCheck?: 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'; autoCapitalize?: 'none' | 'sentences';
/** Fill the available height instead of growing with the content. */ /** Fill the available height instead of growing with the content. */
fillContainer?: boolean; fillContainer?: boolean;
@@ -157,7 +161,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
placeholder, placeholder,
editable = true, editable = true,
spellCheck = false, spellCheck = false,
autoCorrect = false, autoCorrect = 'off',
autoCapitalize = 'none', autoCapitalize = 'none',
fillContainer = false, fillContainer = false,
maxLines = 8, maxLines = 8,
@@ -287,7 +291,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
}), }),
EditorView.contentAttributes.of({ EditorView.contentAttributes.of({
spellcheck: String(handlersRef.current.spellCheck ?? false), spellcheck: String(handlersRef.current.spellCheck ?? false),
autocorrect: handlersRef.current.autoCorrect ? 'on' : 'off', autocorrect: handlersRef.current.autoCorrect ?? 'off',
autocapitalize: handlersRef.current.autoCapitalize ?? 'none', autocapitalize: handlersRef.current.autoCapitalize ?? 'none',
...(handlersRef.current['aria-label'] ...(handlersRef.current['aria-label']
? { 'aria-label': handlersRef.current['aria-label'] } ? { 'aria-label': handlersRef.current['aria-label'] }
@@ -454,7 +458,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
if (!view) return; if (!view) return;
const content = view.contentDOM; const content = view.contentDOM;
content.setAttribute('spellcheck', String(spellCheck)); content.setAttribute('spellcheck', String(spellCheck));
content.setAttribute('autocorrect', autoCorrect ? 'on' : 'off'); content.setAttribute('autocorrect', autoCorrect);
content.setAttribute('autocapitalize', autoCapitalize); content.setAttribute('autocapitalize', autoCapitalize);
}, [autoCapitalize, autoCorrect, spellCheck]); }, [autoCapitalize, autoCorrect, spellCheck]);
@@ -0,0 +1,92 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { composerAutoCorrect, type ComposerAutoCorrect } from '../autocorrect';
const platform = (overrides: Partial<Navigator>): 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');
});
});
@@ -0,0 +1,24 @@
export type ComposerAutoCorrect = 'on' | 'off' | 'Off';
type PlatformNavigator = Pick<Navigator,
'maxTouchPoints' | 'platform' | 'userAgent' | 'vendor'
>;
/** 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';
}