fix: preserve composer double-space periods

This commit is contained in:
Ibrahim Khan
2026-07-30 22:27:17 +00:00
parent 09f0c64839
commit 41c3389db6
5 changed files with 135 additions and 6 deletions
@@ -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<ChatInputProps> = ({ 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}
@@ -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
@@ -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<ComposerEditorHandle, ComposerEdi
placeholder,
editable = true,
spellCheck = false,
autoCorrect = false,
autoCorrect = 'off',
autoCapitalize = 'none',
fillContainer = false,
maxLines = 8,
@@ -262,7 +266,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
}),
EditorView.contentAttributes.of({
spellcheck: String(handlersRef.current.spellCheck ?? false),
autocorrect: handlersRef.current.autoCorrect ? 'on' : 'off',
autocorrect: handlersRef.current.autoCorrect ?? 'off',
autocapitalize: handlersRef.current.autoCapitalize ?? 'none',
...(handlersRef.current['aria-label']
? { 'aria-label': handlersRef.current['aria-label'] }
@@ -406,7 +410,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
if (!view) return;
const content = view.contentDOM;
content.setAttribute('spellcheck', String(spellCheck));
content.setAttribute('autocorrect', autoCorrect ? 'on' : 'off');
content.setAttribute('autocorrect', autoCorrect);
content.setAttribute('autocapitalize', autoCapitalize);
}, [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';
}