hermes - 💡(How to fix) Fix [Bug]: Desktop composer drops or fails to send CJK IME text on Enter

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

Error Message

Additional Logs / Traceback

N/A — symptom is silent (no error / traceback).

Root Cause

Two related races in apps/desktop/src/app/chat/composer/index.tsx:

  1. onCompositionEnd does not resync the draft. handleEditorInput (around L571) intentionally skips state writes while composingRef.current is true:

    if (composingRef.current) {
      return
    }

    The committed text is expected to arrive via a follow-up input event after compositionend, but Chromium does not always fire one. As a result, both draftRef.current and the assistant-ui composer state remain stuck at the pre-commit value (often empty or a stale prefix).

  2. submitDraft() reads the React draft state instead of draftRef.current. Around L1056, the slash-command branch and the regular submit branch both do:

    if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) {
      const submitted = draft
    ...
    } else if (draft.trim() || attachments.length > 0) {
      const submitted = draft

    draft comes from useAuiState(s => s.composer.text) (L144) and lags the synchronously-updated draftRef.current by a render. A fast type→Enter sequence within the same frame submits the previous value, which is what produces the truncated-tail variant.

Fix Action

Fix / Workaround

Patch (verified locally; CJK IME submission now works on the first Enter press):

Cannot push to external forks from my current environment. The patch above is verified locally — feel free to apply it directly.

Code Example

if (composingRef.current) {
     return
   }

---

if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) {
     const submitted = draft
   ...
   } else if (draft.trim() || attachments.length > 0) {
     const submitted = draft

---

--- a/apps/desktop/src/app/chat/composer/index.tsx
+++ b/apps/desktop/src/app/chat/composer/index.tsx
@@ -1064,8 +1064,8 @@ export function ChatBar({
       // busy guard for commands that genuinely need an idle session (skill
       // /send directives).  Queuing them would make every slash command wait
       // for the current turn to finish, which is how the TUI never behaves.
-      if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) {
-        const submitted = draft
+      if (!attachments.length && SLASH_COMMAND_RE.test(draftRef.current.trim())) {
+        const submitted = draftRef.current
         triggerHaptic('submit')
         clearDraft()
         void onSubmit(submitted)
@@ -1083,8 +1083,8 @@ export function ChatBar({
       }
     } else if (!hasComposerPayload && queuedPrompts.length > 0) {
       void drainNextQueued()
-    } else if (draft.trim() || attachments.length > 0) {
-      const submitted = draft
+    } else if (draftRef.current.trim() || attachments.length > 0) {
+      const submitted = draftRef.current
       triggerHaptic('submit')
       clearDraft()
       clearComposerAttachments()
@@ -1229,6 +1229,19 @@ export function ChatBar({
         onBlur={() => window.setTimeout(closeTrigger, 80)}
         onCompositionEnd={() => {
           composingRef.current = false
+          // Force draft sync after IME commit. handleEditorInput skips state
+          // writes while composingRef is true, and Chromium does not always
+          // fire a follow-up input event after compositionend, leaving the
+          // draft stuck at the pre-commit value. Without this resync, Enter
+          // sends a truncated message (or nothing if the pre-commit was empty).
+          const editor = editorRef.current
+          if (editor) {
+            const nextDraft = composerPlainText(editor)
+            if (nextDraft !== draftRef.current) {
+              draftRef.current = nextDraft
+              aui.composer().setText(nextDraft)
+            }
+          }
         }}
         onCompositionStart={() => {
           composingRef.current = true
RAW_BUFFERClick to expand / collapse

Bug Description

In the desktop composer, after committing a CJK IME (Japanese / Korean / Chinese) preedit and pressing Enter:

  • The message often fails to send. Pressing Enter does nothing; deleting one character and pressing Enter again finally sends it.
  • Occasionally only the prefix of the typed text is sent and the tail is silently dropped.

Reproduces reliably on macOS with the Japanese (Kotoeri) IME against the shipped release/mac-arm64/Hermes.app.

Steps to Reproduce

  1. Launch Hermes Desktop on macOS with a CJK IME enabled (e.g. Japanese / Kotoeri).
  2. In the composer, switch input to Japanese.
  3. Type テストテスト, confirm the IME preedit (Enter / Space depending on IME state).
  4. Press Enter to submit.

Expected Behavior

The full committed text (テストテスト) is sent on the first Enter press.

Actual Behavior

  • Enter does nothing. Deleting one character and pressing Enter sends the (now-truncated) message.
  • Sometimes Enter sends only the prefix (e.g. テスト instead of テストテスト).

Affected Component

Other (Desktop UI / renderer composer)

Messaging Platform

N/A (CLI only)

Debug Report

Cannot share — recent agent/desktop logs contain confidential conversation content. Happy to share sanitized excerpts privately on request.

Operating System

macOS 26.5 (Apple Silicon, M4)

Hermes Version

Latest auto-updated desktop build as of 2026-06-05; investigated against main at ff5652d0f.

Additional Logs / Traceback

N/A — symptom is silent (no error / traceback).

Root Cause Analysis

Two related races in apps/desktop/src/app/chat/composer/index.tsx:

  1. onCompositionEnd does not resync the draft. handleEditorInput (around L571) intentionally skips state writes while composingRef.current is true:

    if (composingRef.current) {
      return
    }

    The committed text is expected to arrive via a follow-up input event after compositionend, but Chromium does not always fire one. As a result, both draftRef.current and the assistant-ui composer state remain stuck at the pre-commit value (often empty or a stale prefix).

  2. submitDraft() reads the React draft state instead of draftRef.current. Around L1056, the slash-command branch and the regular submit branch both do:

    if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) {
      const submitted = draft
    ...
    } else if (draft.trim() || attachments.length > 0) {
      const submitted = draft

    draft comes from useAuiState(s => s.composer.text) (L144) and lags the synchronously-updated draftRef.current by a render. A fast type→Enter sequence within the same frame submits the previous value, which is what produces the truncated-tail variant.

Proposed Fix

Patch (verified locally; CJK IME submission now works on the first Enter press):

--- a/apps/desktop/src/app/chat/composer/index.tsx
+++ b/apps/desktop/src/app/chat/composer/index.tsx
@@ -1064,8 +1064,8 @@ export function ChatBar({
       // busy guard for commands that genuinely need an idle session (skill
       // /send directives).  Queuing them would make every slash command wait
       // for the current turn to finish, which is how the TUI never behaves.
-      if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) {
-        const submitted = draft
+      if (!attachments.length && SLASH_COMMAND_RE.test(draftRef.current.trim())) {
+        const submitted = draftRef.current
         triggerHaptic('submit')
         clearDraft()
         void onSubmit(submitted)
@@ -1083,8 +1083,8 @@ export function ChatBar({
       }
     } else if (!hasComposerPayload && queuedPrompts.length > 0) {
       void drainNextQueued()
-    } else if (draft.trim() || attachments.length > 0) {
-      const submitted = draft
+    } else if (draftRef.current.trim() || attachments.length > 0) {
+      const submitted = draftRef.current
       triggerHaptic('submit')
       clearDraft()
       clearComposerAttachments()
@@ -1229,6 +1229,19 @@ export function ChatBar({
         onBlur={() => window.setTimeout(closeTrigger, 80)}
         onCompositionEnd={() => {
           composingRef.current = false
+          // Force draft sync after IME commit. handleEditorInput skips state
+          // writes while composingRef is true, and Chromium does not always
+          // fire a follow-up input event after compositionend, leaving the
+          // draft stuck at the pre-commit value. Without this resync, Enter
+          // sends a truncated message (or nothing if the pre-commit was empty).
+          const editor = editorRef.current
+          if (editor) {
+            const nextDraft = composerPlainText(editor)
+            if (nextDraft !== draftRef.current) {
+              draftRef.current = nextDraft
+              aui.composer().setText(nextDraft)
+            }
+          }
         }}
         onCompositionStart={() => {
           composingRef.current = true

PR

Cannot push to external forks from my current environment. The patch above is verified locally — feel free to apply it directly.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING