Skip to content

fix(prompt-input): keep the editor inert during IME composition - #44826

Open
powoct wants to merge 4 commits into
anomalyco:devfrom
powoct:safari-ime-composition-v2
Open

fix(prompt-input): keep the editor inert during IME composition#44826
powoct wants to merge 4 commits into
anomalyco:devfrom
powoct:safari-ime-composition-v2

Conversation

@powoct

@powoct powoct commented Aug 25, 2026

Copy link
Copy Markdown

Issue for this PR

Closes #38674

Supersedes #38728, which the automated cleanup closed before anyone reviewed it. I can't reopen that one — GitHub rejects the reopen — so this is the same fix rebased onto current dev, squashed into three commits, with an unrelated placeholder change split out into a follow-up branch.

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Typing CJK in the web prompt input is broken in two ways in Safari. They have different causes, so both are fixed here.

1. The Enter that confirms an IME candidate submits the message.

Safari fires compositionend before the confirming keydown, so that keydown reports isComposing=false and keyCode=13:

Chrome:  keydown(Enter, isComposing=true, keyCode=229) -> compositionend
Safari:  compositionend -> keydown(Enter, isComposing=false, keyCode=13)

The existing guard is event.isComposing || composing() || event.keyCode === 229. In Safari none of the three match, the Enter falls through to submit, and the message is sent with the raw pinyin still in it. The fix records performance.now() at compositionend and treats any key event within 100ms of it as part of the IME confirmation. Same guard in both editors — v1 (packages/app) and v2 (packages/session-ui).

2. The v2 editor aborts the composition after every keystroke.

That guard alone was not enough against a real deployment. The v2 editor runs dispatch/setState on every keydown and reads and writes the selection on keyup/pointerup while composing. Safari responds by aborting the composition after each letter, so nihao becomes five one-letter compositions and the confirming Enter arrives as a plain keyCode=13 long after any compositionend — nothing is left for the guard to recognize.

The v2 editor is now fully inert while a composition is active, and during the 100ms window after it: no keydown dispatch, no cursor sync, no input handling, no editor DOM rewrites. State is reconciled once from the DOM on compositionend. Safari then keeps the composition as a single segment and the confirming Enter reports keyCode=229, which the guard already handled.

That second part is not Safari-specific, and I believe it addresses #39632 as well — the first keystroke escaping the composition in the v2 editor is the same DOM-rewrite-during-composition cause, which is presumably why the duplicate bot linked the two issues. I reproduced with macOS Pinyin and Japanese rather than Shuangpin, so I'd rather not close that issue automatically; confirmation from its reporters would settle it.

Note on expected behavior: Enter committing the raw romaji instead of 你好 is macOS Pinyin's own design — Space picks the candidate. The bug was that the app submitted the message on that Enter.

How did you verify your code works?

  • Real-device Safari 26 on macOS with the macOS Pinyin and Japanese IMEs, against an instrumented build logging every composition and key event: nihao + Enter no longer submits and the text stays in the box, nihao + Space commits 你好, Japanese live conversion with multi-segment auto-commit is not interrupted, and plain-English Enter / Shift+Enter behavior is unchanged. Chrome regression-tested with the same IMEs.
  • bun test: packages/session-ui 85 pass / 0 fail (including 31 lines of new coverage in machine.test.ts), packages/app 721 pass / 1 fail. The one failure is src/i18n/desktop-native.test.ts, which fails identically on unpatched dev — unrelated to this change.
  • bun run typecheck clean in both packages.
  • Rebased onto dev at 18b4cb6; no conflicts.

Screenshots / recordings

No visual change — the fix only affects which key events reach the submit path and when the editor touches the DOM.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

powoct added 3 commits August 25, 2026 09:44
Safari fires compositionend before the keydown that confirms an IME
candidate, so that keydown reports isComposing=false and keyCode=13.
Treat any key event within 100ms of compositionend (tracked with
performance.now) as part of the IME confirmation so it does not submit.
Apply the same post-compositionend window to the v2 editor: an Enter
arriving within 100ms of compositionend confirms an IME candidate and
must not submit the message.
The v2 editor dispatched on every keydown and read/wrote the selection
on keyup/pointerup while composing. Safari reacts by aborting the
composition after each letter, so `nihao` becomes five one-letter
compositions and the confirming Enter arrives as a plain keyCode=13
long after any compositionend, leaving the guard nothing to match.

Make the editor fully inert while a composition is active and during
the 100ms window after it: no keydown dispatch, no cursor sync, no
input handling, no editor DOM rewrites. State is reconciled once from
the DOM on compositionend. Safari then keeps the composition as a
single segment and the confirming Enter reports keyCode=229.
@anyingiit

anyingiit commented Aug 25, 2026

Copy link
Copy Markdown

Tested this branch — unfortunately it does not fix the issue in my environment.

Environment

  • OS: macOS 26.6 (25G72)
  • Browser: Safari
  • IME: macOS built-in Simplified Chinese Pinyin
  • Build: this branch (server + web app, fresh browser tab)

Result

No special trigger is needed — plain continuous Chinese input fails right away. While typing, every letter except the last one is immediately committed as plain English text; only the final keystroke remains in the IME composition.

This is the same behavior I reported in #38674 (comment). Chrome on the same machine works fine.

Screen recording attached below.
Screenshot 2026-08-26 at 2 37 53

Screen.Recording.2026-08-26.at.2.34.12.mov

@powoct

powoct commented Aug 26, 2026

Copy link
Copy Markdown
Author

@anyingiit Thanks for testing, and for the detailed report and recording — much appreciated.

I tried to reproduce this after updating to macOS 26.6.2 / Safari 26.6.2, typing continuously with the macOS built-in Simplified Chinese Pinyin IME into a production deployment carrying this fix (based on v1.18.22), and composition stays intact there: nihao composes as one unit and converts normally. I also captured full event traces on both Safari 26.5.2 and 26.6.2 — the sequence is identical on both, for every keystroke:

compositionstart
compositionupdate                 data:"n"
beforeinput   insertCompositionText   isComposing:true
input         insertCompositionText   isComposing:true
keydown       keyCode:229             isComposing:true

i.e. compositionstart always precedes the first input, and every composition input carries isComposing: true, so the guards in this branch engage. Your recording shows per-keystroke splitting even on stock, so something in your environment takes a different path — I'd like to pin it down. Could you help with the following?

  1. Exact versions — Safari → About Safari (e.g. 26.6 (21624.x.x.x.x)) and the macOS build; does it also happen in a regular (non-private) window?

  2. Build mode — your recording shows the DEV badge (vite dev server). Does a production build of this branch reproduce it too, or only dev mode?

  3. Event trace — focus the prompt input, paste this into the Web Inspector console, type nihao, and paste the console output here:

    probe script
    (() => {
      const ed = document.querySelector('[data-component="prompt-input"]');
      if (!ed) return console.warn("editor not found");
      const t0 = performance.now();
      const log = (tag, e) => console.log(
        `${(performance.now() - t0).toFixed(1)}ms ${tag}`,
        JSON.stringify({
          isComposing: e.isComposing ?? null,
          inputType: e.inputType ?? null,
          keyCode: e.keyCode ?? null,
          key: e.key ?? null,
          data: e.data ?? null,
        })
      );
      for (const t of ["keydown","keyup","beforeinput","input","compositionstart","compositionupdate","compositionend"])
        ed.addEventListener(t, (e) => log(t, e), { capture: true });
      new MutationObserver((ms) => console.log(
        `${(performance.now() - t0).toFixed(1)}ms DOM-MUTATION x${ms.length}`,
        ms.map((m) => m.type).join(","), "textContent=" + JSON.stringify(ed.textContent)
      )).observe(ed, { childList: true, characterData: true, subtree: true });
      console.log("probe armed");
    })();

    The interesting bits: whether compositionstart fires before or after the first input, the isComposing / inputType values on each event, and where the DOM-MUTATION lines land relative to the composition events.

  4. Control test — open this in the same Safari and type the same way; does composition also split in a bare contenteditable?

    data:text/html,<div contenteditable style="border:1px solid;padding:8px;font-size:16px"></div>
    
  5. Input settings — anything non-default under System Settings → Keyboard (autocorrect, inline predictive text), other input sources installed, or active Safari extensions?

If your trace shows a different order — e.g. the first input arriving before compositionstart with isComposing: false — that would explain exactly why the current guards don't engage on your machine, and I have a follow-up hardening ready for that case.

@anyingiit

anyingiit commented Aug 28, 2026

Copy link
Copy Markdown

@powoct Good news — with a correct local setup, the fix works in my environment, in both normal and private windows, including the cold-start first input. Details and the traces you asked for below. One important correction about my earlier testing first.

A correction about my first reproduction

My original report (and my earlier comment) was against a setup I believed was "this branch, dev mode". It turns out my local opencode serve was serving the production https://app.opencode.ai bundle, not my checkout — when the embedded web UI (opencode-web-ui.gen.ts) is absent, the server falls back to proxying the upstream app. I confirmed this by matching the served bundle hash byte-for-byte with app.opencode.ai (index-Ca69RsoK.js). So the per-letter splitting I reported was the unpatched production behavior, and said nothing about your branch either way. Apologies for the noise.

Correct test against this branch (production build)

I then built the branch locally and served the real patched bundle via vite preview (bundle index-Co4JMD1P.js), with the API proxied to a backend running this checkout (3c820da16). I tested in both a normal window and a private window, three rounds each, with the macOS built-in Simplified Chinese Pinyin IME:

  • Round 1 — cold start, first prompt input in a fresh session: WORKS. Single continuous composition n → ni → ni h → ni ha → ni hao, candidate window appears, Space commits 你好. No per-letter split.
  • Round 2 — after sending one message: WORKS. Same continuous composition, Space commits 你好.
  • Round 3 — after two messages: WORKS. Same. Plain ASCII insert/delete afterwards also normal.

The per-letter split I saw before is gone with the patched build — including the first-input cold-start case, and in both window types.

Answers to your five questions

  1. Versions: Safari 26.6 (21624.4.5.11.5), macOS 26.6 (25G72). My original (broken) repro was in a private window. The patched build works in both normal and private windows.
  2. Build mode: The splitting I originally saw was effectively production (the proxied app.opencode.ai bundle), not vite dev. The patched production build does not reproduce it in either window type.
  3. Event trace (patched production build, cold-start first input, typing nihao then Space):
1611ms compositionstart
1612ms compositionupdate   data:"n"
1612ms beforeinput  insertCompositionText  isComposing:true  data:"n"
1615ms DOM-MUTATION x2  childList,characterData  textContent="n"
1616ms input        insertCompositionText  isComposing:true  data:"n"
1655ms keydown      keyCode:229  isComposing:true  key:"n"
1674ms keyup        keyCode:78   isComposing:true  key:"n"
1763ms compositionupdate   data:"ni"        <- same composition continues, NO compositionend
1927ms compositionupdate   data:"ni h"
2027ms compositionupdate   data:"ni ha"
2087ms compositionupdate   data:"ni hao"
2141ms keyup        keyCode:79   isComposing:true  key:"o"
2252ms beforeinput  deleteCompositionText
2254ms beforeinput  insertFromComposition  data:"你好"
2271ms DOM-MUTATION x3  textContent="你好"
2271ms input        insertFromComposition  data:"你好"
2271ms compositionend  data:"你好"
2276ms keydown      keyCode:229  isComposing:false  key:" "   <- Space picked the candidate
3051ms keydown      keyCode:13   Enter  -> message submitted with 你好

The key difference vs. my broken trace: with the patched build the composition stays open across all five letters (no compositionend until the candidate is committed), whereas the unpatched build fired compositionend after every single keyup.
4. Control test: bare contenteditable, the Safari address bar, and Spotlight all compose 你好 normally — my IME stack is healthy.
5. Input settings: input sources are just the macOS built-ins — Simplified Chinese Pinyin (com.apple.inputmethod.SCIM.ITABC, enabled + selected) plus Japanese; nothing non-default that I'm aware of. One Safari content blocker (AdGuard) is installed and was active during these (passing) tests.

Net: the branch fixes the issue for me. Happy to run any further traces if useful.

Screen.Recording.2026-08-28.at.8.06.43.mov

Nice Work!

@powoct

powoct commented Aug 28, 2026

Copy link
Copy Markdown
Author

@anyingiit Thank you for the thorough follow-up — tracking down the serve fallback and re-testing with the real build is exactly what was needed, and the traces are perfect. Much appreciated.

Summary for reviewers:

  • The earlier "doesn't fix it" report is retracted: the local opencode serve was silently proxying the unpatched production app.opencode.ai bundle (the fallback when the embedded web UI is absent), confirmed by bundle hash. So the per-letter splitting shown there is what production ships today — it is the bug this PR fixes, not a counter-example to it.
  • With the actual patched build, the fix is now verified on two independent environments: macOS 26.6 (25G72) / Safari 26.6 (@anyingiit — normal + private windows, including cold-start first input), and macOS/Safari 26.5.2 → 26.6.2 (mine — production deployment, with event traces captured before and after the OS update, identical ordering).
  • The traces pin the defect signature precisely: unpatched, Safari fires compositionend after every keyup, splitting the composition into per-letter commits so the romaji leaks as plain text and the confirming key degrades to a plain Enter; patched, the composition stays open across all keystrokes and the confirming key arrives as keyCode 229 within the compositionend guard window, so it is correctly ignored.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Safari: CJK IME composition is aborted mid-input in web app prompt input

2 participants