{"repo":"NousResearch/hermes-agent","public_id":"39614","display_label":"Desktop send button missing with committed IME text input","summary":"The members concern the desktop app's send button not appearing when Chinese, Japanese, or Korean text is entered via an IME. Two items share the same title, fix(desktop): show send button for committed IME text, and one is a bug report about Chinese characters making the send button disappear.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":40210,"merged_at":"2026-06-06T01:05:01+00:00"},"resolution":{"state":"resolved","by":"pr-40210"},"freshness":{"state":"verified","last_verified_at":"2026-06-06T01:05:01+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":39614,"pr_number":40210,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-06T01:05:01+00:00"}],"members":[{"kind":"issue","number":39614,"title":"[Bug]:  Chinese Character Send Button Disappear Bug","url":null,"state":"closed"},{"kind":"pr","number":39650,"title":"fix(desktop): show send button for committed IME text (Chinese/Japanese/Korean)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#40210","number":40210}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":40210,"title":"fix(desktop): show send button for committed IME text (Chinese/Japanese/Korean)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/src/app/chat/composer/ime-composition-dom-repro.test.tsx","apps/desktop/src/app/chat/composer/index.tsx"],"fix_hunks":[{"file":"apps/desktop/src/app/chat/composer/ime-composition-dom-repro.test.tsx","hunks":["@@ -0,0 +1,108 @@\n+import { act, cleanup, fireEvent, render } from '@testing-library/react'\n+import { useRef, useState } from 'react'\n+import { afterEach, describe, expect, it } from 'vitest'\n+\n+// No global setupFiles registers auto-cleanup, so unmount between tests —\n+// otherwise a second render() leaks the first editor and getByTestId('editor')\n+// matches multiple nodes.\n+afterEach(cleanup)\n+\n+// Faithful mirror of index.tsx's composer text wiring for IME input, driven\n+// through REAL DOM composition + input events on a contentEditable.\n+//\n+// Regression repro for #39614: typing committed multi-character IME text (e.g.\n+// Chinese \"你好\") used to leave the send button hidden. The input events fired\n+// during composition carry uncommitted preedit text and are intentionally\n+// skipped; Chromium then does NOT reliably emit a trailing input event after\n+// compositionend on Windows IMEs, so the finalized text never reached composer\n+// state and `hasPayload` stayed false until an unrelated edit forced a sync.\n+// The fix flushes the live DOM text in onCompositionEnd.\n+function Harness({ onPayload }: { onPayload: (hasPayload: boolean) => void }) {\n+  const editorRef = useRef<HTMLDivElement>(null)\n+  const composingRef = useRef(false)\n+  const draftRef = useRef('')\n+  const [draft, setDraft] = useState('')\n+\n+  const flushEditorToDraft = (editor: HTMLDivElement) => {\n+    const next = editor.textContent ?? ''\n+\n+    if (next !== draftRef.current) {\n+      draftRef.current = next\n+      setDraft(next)\n+    }\n+  }\n+\n+  onPayload(draft.trim().length > 0)\n+\n+  return (\n+    <div\n+      contentEditable\n+      data-testid=\"editor\"\n+      onCompositionEnd={event => {\n+        composingRef.current = false\n+        flushEditorToDraft(event.currentTarget)\n+      }}\n+      onCompositionStart={() => {\n+        composingRef.current = true\n+      }}\n+      onInput={event => {\n+        if (composingRef.current) {\n+          return\n+        }\n+\n+        flushEditorToDraft(event.currentTarget)\n+      }}\n+      ref={editorRef}\n+      suppressContentEditableWarning\n+    />\n+  )\n+}\n+\n+describe('composer IME composition — send button visibility (#39614)', () => {\n+  it('shows the send button after committing CJK text without a trailing edit', async () => {\n+    let hasPayload = false\n+    const { getByTestId } = render(<Harness onPayload={p => (hasPayload = p)} />)\n+    const editor = getByTestId('editor')\n+\n+    // Compose \"你好\" the way a Windows Chinese IME does: compositionstart, then\n+    // input events carrying uncommitted preedit text, then compositionend with\n+    // the committed text already in the DOM — and crucially NO input event\n+    // afterwards.\n+    await act(async () => {\n+      fireEvent.compositionStart(editor)\n+      editor.textContent = '你'\n+      fireEvent.input(editor)\n+      editor.textContent = '你好'\n+      fireEvent.input(editor)\n+      fireEvent.compositionEnd(editor)\n+    })\n+\n+    // Before the fix this was false (button hidden) until a further edit.\n+    expect(hasPayload).toBe(true)\n+    expect(editor.textContent).toBe('你好')\n+  })\n+\n+  it('also covers Japanese/Korean and any IME-composed script', async () => {\n+    let hasPayload = false\n+    const { getByTestId } = render(<Harness onPayload={p => (hasPayload = p)} />)\n+    const editor = getByTestId('editor')\n+\n+    for (const committed of ['こんにちは', '안녕하세요']) {\n+      await act(async () => {\n+        fireEvent.compositionStart(editor)\n+        editor.textContent = committed\n+        fireEvent.input(editor)\n+        fireEvent.compositionEnd(editor)\n+      })\n+\n+      expect(hasPayload).toBe(true)\n+\n+      // Clear for the next script.\n+      await act(async () => {\n+        editor.textContent = ''\n+        fireEvent.input(editor)\n+      })\n+      expect(hasPayload).toBe(false)\n+    }\n+  })\n+})"]},{"file":"apps/desktop/src/app/chat/composer/index.tsx","hunks":["@@ -549,16 +549,10 @@ export function ChatBar({\n     }\n   }, [trigger])\n \n-  const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {\n-    // During IME composition the DOM contains uncommitted preedit text\n-    // mixed with real content.  Skip state writes — compositionend will\n-    // deliver the finalized text via a clean input event.\n-    if (composingRef.current) {\n-      return\n-    }\n-\n-    const editor = event.currentTarget\n-\n+  // Pull the live contentEditable text into draftRef + the AUI composer state\n+  // (which drives `hasComposerPayload` → the send button). Shared by the input\n+  // and compositionend paths so committed IME text reaches state through either.\n+  const flushEditorToDraft = (editor: HTMLDivElement) => {\n     if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {\n       editor.replaceChildren()\n     }","@@ -573,6 +567,17 @@ export function ChatBar({\n     window.setTimeout(refreshTrigger, 0)\n   }\n \n+  const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {\n+    // During IME composition the DOM contains uncommitted preedit text\n+    // mixed with real content.  Skip state writes — compositionend flushes\n+    // the finalized text (see onCompositionEnd).\n+    if (composingRef.current) {\n+      return\n+    }\n+\n+    flushEditorToDraft(event.currentTarget)\n+  }\n+\n   const triggerAdapter: Unstable_TriggerAdapter | null =\n     trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null\n ","@@ -1208,8 +1213,17 @@ export function ChatBar({\n         data-placeholder={placeholder}\n         data-slot={RICH_INPUT_SLOT}\n         onBlur={() => window.setTimeout(closeTrigger, 80)}\n-        onCompositionEnd={() => {\n+        onCompositionEnd={event => {\n           composingRef.current = false\n+\n+          // The input events fired *during* composition were skipped (they\n+          // carried uncommitted preedit text), and Chromium does NOT reliably\n+          // emit a trailing input event after compositionend on Windows IMEs.\n+          // Without flushing here, committed multi-character IME input (e.g.\n+          // Chinese \"你好\", Japanese, Korean) never reaches composer state, so\n+          // `hasComposerPayload` stays false and the send button stays hidden\n+          // until an unrelated edit forces a sync (#39614).\n+          flushEditorToDraft(event.currentTarget)\n         }}\n         onCompositionStart={() => {\n           composingRef.current = true\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}