From 7ff86a3bc75ab71127ce08f5a70bc95f605ad66d Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Wed, 5 Aug 2026 13:44:36 +0300 Subject: [PATCH] fix(composer): restore Shift+Enter newline on iOS CodeMirror defers Enter on iOS (and Chrome Android): the real keydown is captured without running the keymaps and the keymaps then run against a synthetic keydown that dispatchKey builds from the key name alone, with no modifier keys. The composer's Shift+Enter thus arrived as a plain Enter, and on devices where Enter sends (iPad Safari/PWA, where the desktop layout applies) it submitted the message instead of inserting a newline. Record the real Enter keydown's shift state on the view's contentDOM and restore it onto the deferred synthetic event before the caller's onKeyDown policy runs, so Shift+Enter means newline again on every runtime. Plain Enter behavior is untouched: on iOS it still follows the same deferred path it used before this change. Fixes #2558 --- .../chat/composer/editor/ComposerEditor.tsx | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx b/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx index f3715dd7..41a2825f 100644 --- a/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx +++ b/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx @@ -130,6 +130,16 @@ function insertedTextOf(transaction: { changes: { iterChanges: (fn: (fromA: numb return inserted; } +/** + * True for keydown events CodeMirror re-dispatches after deferring the real + * one (iOS Enter/Backspace/Delete, Chrome Android Enter): `dispatchKey` + * stamps the replacement event with a `synthetic` expando. These events are + * built from the key name alone, so they carry no modifier keys. + */ +function isDeferredSyntheticEvent(event: KeyboardEvent): boolean { + return Boolean((event as unknown as { synthetic?: boolean }).synthetic); +} + /** * Compartments are configuration keys, not per-view state, so one set can serve * every editor. They live at module scope because a kept-alive view outlives @@ -160,6 +170,14 @@ export const ComposerEditor = React.forwardRef(null); const viewRef = React.useRef(null); + // The real keydown's shift state for the LAST Enter that reached the + // editor. CodeMirror defers Enter on iOS (and Chrome Android) and + // re-dispatches it as a synthetic keydown built from the key name + // alone, dropping every modifier (see `trackRealEnterShift` and the + // `interceptKeys` handler below); this ref is what lets the deferred + // event still tell Shift+Enter from Enter. + const lastRealEnterShiftRef = React.useRef(false); + // Callbacks reach the CodeMirror extensions through a ref: the view is // built once and must not be torn down when a handler identity changes, // which would drop focus mid-typing. When a view store is supplied the @@ -200,7 +218,15 @@ export const ComposerEditor = React.forwardRef handlersRef.current.onKeyDown?.(event) ?? false, + any: (_view, event) => { + // A deferred Enter lost its modifiers in the re-dispatch; + // give the caller's policy (Enter vs Shift+Enter) back the + // shift state it saw on the real keydown. + if (event.key === 'Enter' && isDeferredSyntheticEvent(event) && lastRealEnterShiftRef.current) { + Object.defineProperty(event, 'shiftKey', { value: true }); + } + return handlersRef.current.onKeyDown?.(event) ?? false; + }, }]; const view = new EditorView({ @@ -276,6 +302,25 @@ export const ComposerEditor = React.forwardRef { + if (event.key !== 'Enter' || isDeferredSyntheticEvent(event)) return; + lastRealEnterShiftRef.current = event.shiftKey; + }; + view.contentDOM.addEventListener('keydown', trackRealEnterShift); + return () => { viewRef.current = null; // A stored view is detached, not destroyed: the store owns its