{"repo":"NousResearch/hermes-agent","public_id":"44278","display_label":"Desktop IME input and Enter submit fixes","summary":"These issues and pull requests address bugs in the desktop client where Korean IME (and similar input methods) cause characters to be dropped or messages to split incorrectly on Enter submission. Fixes focus on flushing draft state and handling IME composition events properly before sending.","subsystem":"apps/desktop","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":39639,"merged_at":"2026-06-10T00:51:24+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-10T00:51:24+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":39630,"pr_number":39639,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-10T00:51:24+00:00"},{"issue_number":44278,"pr_number":39639,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-10T00:51:24+00:00"}],"members":[{"kind":"issue","number":44278,"title":"Desktop: Korean IME drops final syllable on Enter submit","url":null,"state":"closed"},{"kind":"pr","number":39639,"title":"fix(desktop): send on Enter from live editor text, not stale composer state","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/enter-submit-dom-race.test.tsx","apps/desktop/src/app/chat/composer/index.tsx"],"fix_hunks":[{"file":"apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx","hunks":["@@ -0,0 +1,189 @@\n+import { act, cleanup, fireEvent, render } from '@testing-library/react'\n+import { useRef, useState } from 'react'\n+import { afterEach, describe, expect, it, vi } 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 Enter wiring (handleEditorKeyDown's Enter\n+// branch + submitDraft), driven through REAL DOM keydown events on a\n+// contentEditable.\n+//\n+// Regression repro for #39630: pressing Enter right after typing (fast typing /\n+// IME) did nothing. The composer state (`draft` from useAuiState) and its\n+// derived `hasComposerPayload` lag the DOM by a render, so the keydown handler\n+// read empty state and either dropped the message, drained a queued prompt\n+// instead of sending, or (while busy) refused to queue. The fix reads the live\n+// editor text — `hasLivePayload` in the handler and a DOM re-sync at the top of\n+// submitDraft — so the just-typed text always wins.\n+//\n+// We model the race deterministically the way the IME repro does: mutate the\n+// editor's textContent WITHOUT firing an input event, so the React `draft`\n+// state stays stale while the DOM already holds the text.\n+function Harness({\n+  busy = false,\n+  queued = [],\n+  onSubmit,\n+  onQueue,\n+  onCancel,\n+  onDrain\n+}: {\n+  busy?: boolean\n+  queued?: readonly string[]\n+  onSubmit: (text: string) => void\n+  onQueue: (text: string) => void\n+  onCancel: () => void\n+  onDrain: () => void\n+}) {\n+  const editorRef = useRef<HTMLDivElement>(null)\n+  const draftRef = useRef('')\n+  // Mirrors `useAuiState(s => s.composer.text)` — updated only via setText, so\n+  // it lags the DOM until React re-renders (the source of the bug).\n+  const [draft, setDraft] = useState('')\n+  const attachments: unknown[] = []\n+\n+  const composerPlainText = (el: HTMLElement) => el.textContent ?? ''\n+\n+  const setText = (next: string) => {\n+    draftRef.current = next\n+    setDraft(next)\n+  }\n+\n+  const submitDraft = () => {\n+    const editor = editorRef.current\n+    if (editor) {\n+      const domText = composerPlainText(editor)\n+      if (domText !== draftRef.current) {\n+        draftRef.current = domText\n+        setDraft(domText)\n+      }\n+    }\n+\n+    const text = draftRef.current\n+    const payloadPresent = text.trim().length > 0 || attachments.length > 0\n+\n+    if (busy) {\n+      if (payloadPresent) {\n+        onQueue(text)\n+      } else {\n+        onCancel()\n+      }\n+    } else if (!payloadPresent && queued.length > 0) {\n+      onDrain()\n+    } else if (payloadPresent) {\n+      onSubmit(text)\n+    }\n+  }\n+\n+  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n+    if (event.key === 'Enter' && !event.shiftKey) {\n+      event.preventDefault()\n+\n+      const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current\n+      const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0\n+\n+      if (!busy && !hasLivePayload && queued.length > 0) {\n+        onDrain()\n+\n+        return\n+      }\n+\n+      if (busy && !hasLivePayload) {\n+        return\n+      }\n+\n+      submitDraft()\n+    }\n+  }\n+\n+  // `draft` is read so the lint/compiler treats the stale-state mirror as live;\n+  // the assertions prove the handler never relies on it.\n+  void draft\n+\n+  return (\n+    <div\n+      contentEditable\n+      data-testid=\"editor\"\n+      onInput={event => setText(composerPlainText(event.currentTarget))}\n+      onKeyDown={handleKeyDown}\n+      ref={editorRef}\n+      suppressContentEditableWarning\n+    />\n+  )\n+}\n+\n+describe('composer Enter submit — live DOM vs stale composer state (#39630)', () => {\n+  it('sends the just-typed text on Enter even when composer state has not synced', async () => {\n+    const onSubmit = vi.fn()\n+    const { getByTestId } = render(\n+      <Harness onCancel={vi.fn()} onDrain={vi.fn()} onQueue={vi.fn()} onSubmit={onSubmit} />\n+    )\n+    const editor = getByTestId('editor')\n+\n+    // Fast typing: the DOM has the text but NO input event fired, so `draft`\n+    // state is still empty (the exact stale-state race).\n+    await act(async () => {\n+      editor.textContent = 'hello world'\n+      fireEvent.keyDown(editor, { key: 'Enter' })\n+    })\n+\n+    expect(onSubmit).toHaveBeenCalledWith('hello world')\n+  })\n+\n+  it('queues a fast-typed message while busy instead of draining the queue or cancelling', async () => {\n+    const onQueue = vi.fn()\n+    const onDrain = vi.fn()\n+    const onCancel = vi.fn()\n+    const { getByTestId } = render(\n+      <Harness busy onCancel={onCancel} onDrain={onDrain} onQueue={onQueue} onSubmit={vi.fn()} queued={['queued-1']} />\n+    )\n+    const editor = getByTestId('editor')\n+\n+    await act(async () => {\n+      editor.textContent = 'urgent follow-up'\n+      fireEvent.keyDown(editor, { key: 'Enter' })\n+    })\n+\n+    expect(onQueue).toHaveBeenCalledWith('urgent follow-up')\n+    expect(onDrain).not.toHaveBeenCalled()\n+    expect(onCancel).not.toHaveBeenCalled()\n+  })\n+\n+  it('treats an empty Enter while busy as a no-op (never an accidental Stop)', async () => {\n+    const onCancel = vi.fn()\n+    const onSubmit = vi.fn()\n+    const onQueue = vi.fn()\n+    const { getByTestId } = render(\n+      <Harness busy onCancel={onCancel} onDrain={vi.fn()} onQueue={onQueue} onSubmit={onSubmit} />\n+    )\n+    const editor = getByTestId('editor')\n+\n+    await act(async () => {\n+      editor.textContent = ''\n+      fireEvent.keyDown(editor, { key: 'Enter' })\n+    })\n+\n+    expect(onCancel).not.toHaveBeenCalled()\n+    expect(onSubmit).not.toHaveBeenCalled()\n+    expect(onQueue).not.toHaveBeenCalled()\n+  })\n+\n+  it('drains the next queued prompt on Enter when idle with a truly empty editor', async () => {\n+    const onDrain = vi.fn()\n+    const onSubmit = vi.fn()\n+    const { getByTestId } = render(\n+      <Harness onCancel={vi.fn()} onDrain={onDrain} onQueue={vi.fn()} onSubmit={onSubmit} queued={['queued-1']} />\n+    )\n+    const editor = getByTestId('editor')\n+\n+    await act(async () => {\n+      editor.textContent = ''\n+      fireEvent.keyDown(editor, { key: 'Enter' })\n+    })\n+\n+    expect(onDrain).toHaveBeenCalledTimes(1)\n+    expect(onSubmit).not.toHaveBeenCalled()\n+  })\n+})"]},{"file":"apps/desktop/src/app/chat/composer/index.tsx","hunks":["@@ -814,7 +814,16 @@ export function ChatBar({\n     if (event.key === 'Enter' && !event.shiftKey) {\n       event.preventDefault()\n \n-      if (!busy && !hasComposerPayload && queuedPrompts.length > 0) {\n+      // Decide from the DOM, not React state. `hasComposerPayload` is derived\n+      // from the AUI composer state, which lags the latest keystroke by a\n+      // render, so on fast typing / IME the just-typed text isn't in state yet.\n+      // Without the live read, a real message typed while prompts are queued\n+      // would drain the queue instead of sending. submitDraft() re-syncs and\n+      // sends the live editor text.\n+      const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current\n+      const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0\n+\n+      if (!busy && !hasLivePayload && queuedPrompts.length > 0) {\n         void drainNextQueued()\n \n         return","@@ -822,7 +831,10 @@ export function ChatBar({\n \n       // Empty Enter while busy is a no-op — interrupting is explicit (Stop/Esc),\n       // never a stray Enter after sending. With a payload, submitDraft queues it.\n-      if (busy && !hasComposerPayload) {\n+      // Gate on the live DOM payload (not the render-lagged composer state) so a\n+      // message typed fast / via IME while busy still reaches submitDraft() and\n+      // gets queued instead of being mistaken for an empty Enter.\n+      if (busy && !hasLivePayload) {\n         return\n       }\n ","@@ -1212,6 +1224,26 @@ export function ChatBar({\n   }, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps\n \n   const submitDraft = () => {\n+    // Source the text from the DOM editor, not React state. The AUI composer\n+    // state (`draft`) and the derived `hasComposerPayload` lag the DOM by a\n+    // render, so on fast typing or IME composition the final keystroke(s) may\n+    // not have synced yet — reading state here drops the message (Enter looks\n+    // like it does nothing; typing a trailing space only \"fixes\" it because the\n+    // extra input event forces a state sync). draftRef is updated on every\n+    // input event; refresh it from the editor once more to also cover an\n+    // in-flight keystroke that hasn't fired its input event yet.\n+    const editor = editorRef.current\n+    if (editor) {\n+      const domText = composerPlainText(editor)\n+      if (domText !== draftRef.current) {\n+        draftRef.current = domText\n+        aui.composer().setText(domText)\n+      }\n+    }\n+\n+    const text = draftRef.current\n+    const payloadPresent = text.trim().length > 0 || attachments.length > 0\n+\n     if (queueEdit) {\n       exitQueuedEdit('save')\n     } else if (busy) {","@@ -1222,12 +1254,12 @@ export function ChatBar({\n       // busy guard for commands that genuinely need an idle session (skill\n       // /send directives).  Queuing them would make every slash command wait\n       // for the current turn to finish, which is how the TUI never behaves.\n-      if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) {\n-        const submitted = draft\n+      if (!attachments.length && SLASH_COMMAND_RE.test(text.trim())) {\n+        const submitted = text\n         triggerHaptic('submit')\n         clearDraft()\n         void onSubmit(submitted)\n-      } else if (hasComposerPayload) {\n+      } else if (payloadPresent) {\n         queueCurrentDraft()\n       } else {\n         // Stop button (the only way to reach here while busy with an empty","@@ -1235,10 +1267,10 @@ export function ChatBar({\n         triggerHaptic('cancel')\n         void Promise.resolve(onCancel())\n       }\n-    } else if (!hasComposerPayload && queuedPrompts.length > 0) {\n+    } else if (!payloadPresent && queuedPrompts.length > 0) {\n       void drainNextQueued()\n-    } else if (draft.trim() || attachments.length > 0) {\n-      const submitted = draft\n+    } else if (payloadPresent) {\n+      const submitted = text\n       triggerHaptic('submit')\n       resetBrowseState(sessionId)\n       clearDraft()\n"]}]},{"kind":"pr","number":44304,"title":"fix(desktop): prevent Korean IME final syllable drop on Enter submit (#44278)","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":44332,"title":"fix(desktop): prevent Korean IME final syllable drop on Enter submit (#44278)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"0caa23788f6016d3b3216b22fa0203a93152bb99","number":0},{"kind":"commit","value":"8b84d82227a3b637a26ba5f4f41bb08281e2ab84","number":0},{"kind":"pr","value":"#38333","number":38333},{"kind":"pr","value":"#39639","number":39639}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}