Skip to content

Chat composer cancels IME composition mid-keystroke and commits pre-edit text as literal characters #5763

Closed

Description

@ligjn

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 nihao with 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

  1. Run a dev build from main (post-refactor(ui): migrate settings and assistant chat surfaces #5683, e.g. ac4e671eb), open the normal chat surface.
  2. Switch the keyboard to a pinyin IME.
  3. Type nihao (the candidate window follows along, pre-edit visible in the composer).
  4. Press Enter to commit the composition.
  5. 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 SyncPlugin already owns editor-state → composer-text sync (composition-safe: it guards editor.isComposing() and updates lastSyncedTextRef in lockstep so its own writes never trigger a rebuild), and the host's ComposerTextBridge in AssistantUiChat.tsx already owns draft ↔ store programmatic writes (dictation, clear-after-send, draft restore). Both shipped in the same commit as the handler.
  • A DOM textContent read 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.setText sync while a composition is active (a ref set by compositionstart/compositionend, plus an isComposing check on the input event itself).
  • On compositionend, sync once — reading textContent deferred 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

Activity

  1. added a commit that references this issue on Aug 26, 2026
    e2ad37b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions