Repository navigation
Chat composer cancels IME composition mid-keystroke and commits pre-edit text as literal characters #5763
Description
Summary
Typing with a CJK IME in the main chat composer cancels the in-flight composition mid-keystroke: every pre-edit stage is committed as literal text. Typing the pinyin nihao and committing leaves n ni nihao 你好 in the box instead of 你好. Plain English input is unaffected.
Problem
What happens vs expected
- Expected: type
nihaowith a pinyin IME, press Enter to commit → the composer contains你好. - Actual: the composer contains
n ni nihao 你好— the composition was cancelled and re-started several times, and each interrupted pre-edit stage was committed as literal characters before the final commit.
Reproduced with several Chinese IMEs on macOS; the mechanism is IME-agnostic (any composition-based input — Chinese/Japanese/Korean — hits it), and platform-independent.
Steps to reproduce
- Run a dev build from
main(post-refactor(ui): migrate settings and assistant chat surfaces #5683, e.g.ac4e671eb), open the normal chat surface. - Switch the keyboard to a pinyin IME.
- Type
nihao(the candidate window follows along, pre-edit visible in the composer). - Press Enter to commit the composition.
- The composer shows
n ni nihao 你好.
Root cause
app/src/components/assistant-ui/thread.tsx (introduced in #5683) attaches an onInputCapture handler to LexicalComposerInput that reads the raw DOM textContent on every input event and pushes it into the composer store:
onInputCapture={event => {
const target = event.target;
if (target instanceof HTMLElement) {
const text = target.textContent ?? '';
globalThis.queueMicrotask(() => aui.composer.setText(text));
}
}}During an IME composition, input events fire per keystroke (insertCompositionText) and the DOM textContent includes the pre-edit text that Lexical has not yet committed to editor state. Pushing that string into the store makes it disagree with SyncPlugin's lastSyncedTextRef, so the plugin's runtime→Lexical apply path rebuilds the editor (root.clear() + re-create) mid-composition. The composition node is destroyed, the IME cancels the composition and commits the current pre-edit as literal text, and the cycle repeats for the next keystrokes — leaving n, ni, nihao committed literally before the final 你好.
English typing never triggers it because there is no composition: the round-trip is idempotent for committed text.
Two more notes from the same code path:
- The sync is redundant: the package's
SyncPluginalready owns editor-state → composer-text sync (composition-safe: it guardseditor.isComposing()and updateslastSyncedTextRefin lockstep so its own writes never trigger a rebuild), and the host'sComposerTextBridgeinAssistantUiChat.tsxalready owns draft ↔ store programmatic writes (dictation, clear-after-send, draft restore). Both shipped in the same commit as the handler. - A DOM
textContentread also disagrees with the runtime's serialization of directive chips (labels are rendered, the store serializes) — a latent text-corruption source for any host that supplies slash-command chips, same mechanism.
Upstream treats "never interrupt an in-flight composition" as a first-class invariant — assistant-ui#4506, assistant-ui#4510, assistant-ui#4513, assistant-ui#5416 are the precedents (all fixed in the same direction: remove the mid-composition write-back).
Version / platform: main @ ac4e671eb (2026-08-25), macOS desktop dev build (pnpm dev:app). Regression window: since #5683 (2026-08-23).
Solution (optional)
Gate the existing DOM→store bridge on composition state rather than removing it:
- Skip the
onInputCapture→aui.composer.setTextsync while a composition is active (a ref set bycompositionstart/compositionend, plus anisComposingcheck on the input event itself). - On
compositionend, sync once — readingtextContentdeferred by a macrotask, because WebKit finalizes the composed DOM only after the event fires.
Why gate instead of delete: the bridge turns out to be load-bearing in jsdom. Lexical's editor-state commits never fire there (no beforeinput), and the Conversations render tests drive the composer by setting textContent + firing a synthetic input event (setComposerText in Conversations.render.test.tsx) — the bridge is the only path from that input to the composer store, so removing it fails 22 tests. In a real browser the package's SyncPlugin already syncs editor state → store (composition-safe), so the gated bridge is redundant there for plain text and inert during composition.
Upstream treats "never interrupt an in-flight composition" as an invariant — the fix direction matches assistant-ui#4506/#4510/#4513/#5416.
Acceptance criteria
- Repro gone — typing
nihao+ Enter yields你好only (verified manually on macOS with pinyin; Japanese/Korean IMEs share the mechanism). - Regression safety — IME composition cannot be driven faithfully in jsdom (cf. Add E2E coverage for ChipTabs keyboard traversal #5675's rationale for the E2E tier); the preserved behavior is owned by
@assistant-ui/react-lexical's own sync. A browser-level E2E spec would be the right long-term guard. - Diff coverage ≥ 80% — the fix is deletion-only (no new executable lines), so the changed-lines gate is met vacuously.
- English input, Enter-to-send, send-then-clear, and draft persistence behave unchanged.
Related
- Introduced by refactor(ui): migrate settings and assistant chat surfaces #5683 (
feat(chat): wire assistant-ui to real conversation data, 2026-08-23) - Migration tracking: Follow-up: port remaining legacy conversation affordances to assistant-ui #5685
- Upstream precedents: React Native: ComposerPrimitive.Input interrupts IME composition assistant-ui/assistant-ui#4506, docs(plans): phased plan for RLM language-based workflows (tinyagents rhai REPL) #4510, docs: TinyCortex memory migration plan — audit, ownership split, workstreams #4513 (fix), security/policy: a missing workspace is reported as a path-traversal escape #5416
Activity
fix(composer): guard the input write-back instead of removing it
test(chat): cover the pre-stream failure path (tinyhumansai#5729) and…
test(chat): cover the pre-stream failure path (tinyhumansai#5729) and…
test(chat): cover the pre-stream failure path (tinyhumansai#5729) and…
test(chat): cover the pre-stream failure path (tinyhumansai#5729) and…
test(chat): cover the pre-stream failure path (tinyhumansai#5729) and…
test(chat): cover the pre-stream failure path (tinyhumansai#5729) and…
fix(chat): drop a deferred composer write once the next composition b…
fix(chat): stop the composer bridge from cancelling IME compositions
fix(composer): stop the DOM bridge racing Lexical and reordering type…
test(chat): cover the pre-stream failure path (tinyhumansai#5729) and…
fix(chat): drop a deferred composer write once the next composition b…
fix(composer): stop the DOM bridge racing Lexical and reordering type…
fix(chat): stop the composer bridge from cancelling IME compositions\…
fix(chat): leave the selection alone when focusing an empty composer
fix(chat): leave the selection alone when focusing an empty composer