NousResearch/hermes-agent

Desktop IME input and Enter submit fixes

Cluster 44278 · apps/desktop. Data as of

Nothing open on GitHub. Fix merged in #39639 on 10 Jun 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #39639. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #39639 fix(desktop): send on Enter from live editor text, not stale composer state

    merged to main

    files: apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx, apps/desktop/src/app/chat/composer/index.tsx

    Show the diff
    • apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx
      @@ -0,0 +1,189 @@
      +import { act, cleanup, fireEvent, render } from '@testing-library/react'
      +import { useRef, useState } from 'react'
      +import { afterEach, describe, expect, it, vi } from 'vitest'
      +
      +// No global setupFiles registers auto-cleanup, so unmount between tests —
      +// otherwise a second render() leaks the first editor and getByTestId('editor')
      +// matches multiple nodes.
      +afterEach(cleanup)
      +
      +// Faithful mirror of index.tsx's Enter wiring (handleEditorKeyDown's Enter
      +// branch + submitDraft), driven through REAL DOM keydown events on a
      +// contentEditable.
      +//
      +// Regression repro for #39630: pressing Enter right after typing (fast typing /
      +// IME) did nothing. The composer state (`draft` from useAuiState) and its
      +// derived `hasComposerPayload` lag the DOM by a render, so the keydown handler
      +// read empty state and either dropped the message, drained a queued prompt
      +// instead of sending, or (while busy) refused to queue. The fix reads the live
      +// editor text — `hasLivePayload` in the handler and a DOM re-sync at the top of
      +// submitDraft — so the just-typed text always wins.
      +//
      +// We model the race deterministically the way the IME repro does: mutate the
      +// editor's textContent WITHOUT firing an input event, so the React `draft`
      +// state stays stale while the DOM already holds the text.
      +function Harness({
      +  busy = false,
      +  queued = [],
      +  onSubmit,
      +  onQueue,
      +  onCancel,
      +  onDrain
      +}: {
      +  busy?: boolean
      +  queued?: readonly string[]
      +  onSubmit: (text: string) => void
      +  onQueue: (text: string) => void
      +  onCancel: () => void
      +  onDrain: () => void
      +}) {
      +  const editorRef = useRef<HTMLDivElement>(null)
      +  const draftRef = useRef('')
      +  // Mirrors `useAuiState(s => s.composer.text)` — updated only via setText, so
      +  // it lags the DOM until React re-renders (the source of the bug).
      +  const [draft, setDraft] = useState('')
      +  const attachments: unknown[] = []
      +
      +  const composerPlainText = (el: HTMLElement) => el.textContent ?? ''
      +
      +  const setText = (next: string) => {
      +    draftRef.current = next
      +    setDraft(next)
      +  }
      +
      +  const submitDraft = () => {
      +    const editor = editorRef.current
      +    if (editor) {
      +      const domText = composerPlainText(editor)
      +      if (domText !== draftRef.current) {
      +        draftRef.current = domText
      +        setDraft(domText)
      +      }
      +    }
      +
      +    const text = draftRef.current
      +    const payloadPresent = text.trim().length > 0 || attachments.length > 0
      +
      +    if (busy) {
      +      if (payloadPresent) {
      +        onQueue(text)
      +      } else {
      +        onCancel()
      +      }
      +    } else if (!payloadPresent && queued.length > 0) {
      +      onDrain()
      +    } else if (payloadPresent) {
      +      onSubmit(text)
      +    }
      +  }
      +
      +  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
      +    if (event.key === 'Enter' && !event.shiftKey) {
      +      event.preventDefault()
      +
      +      const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
      +      const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
      +
      +      if (!busy && !hasLivePayload && queued.length > 0) {
      +        onDrain()
      +
      +        return
      +      }
      +
      +      if (busy && !hasLivePayload) {
      +        return
      +      }
      +
      +      submitDraft()
      +    }
      +  }
      +
      +  // `draft` is read so the lint/compiler treats the stale-state mirror as live;
      +  // the assertions prove the handler never relies on it.
      +  void draft
      +
      +  return (
      +    <div
      +      contentEditable
      +      data-testid="editor"
      +      onInput={event => setText(composerPlainText(event.currentTarget))}
      +      onKeyDown={handleKeyDown}
      +      ref={editorRef}
      +      suppressContentEditableWarning
      +    />
      +  )
      +}
      +
      +describe('composer Enter submit — live DOM vs stale composer state (#39630)', () => {
      +  it('sends the just-typed text on Enter even when composer state has not synced', async () => {
      +    const onSubmit = vi.fn()
      +    const { getByTestId } = render(
      +      <Harness onCancel={vi.fn()} onDrain={vi.fn()} onQueue={vi.fn()} onSubmit={onSubmit} />
      +    )
      +    const editor = getByTestId('editor')
      +
      +    // Fast typing: the DOM has the text but NO input event fired, so `draft`
      +    // state is still empty (the exact stale-state race).
      +    await act(async () => {
      +      editor.textContent = 'hello world'
      +      fireEvent.keyDown(editor, { key: 'Enter' })
      +    })
      +
      +    expect(onSubmit).toHaveBeenCalledWith('hello world')
      +  })
      +
      +  it('queues a fast-typed message while busy instead of draining the queue or cancelling', async () => {
      +    const onQueue = vi.fn()
      +    const onDrain = vi.fn()
      +    const onCancel = vi.fn()
      +    const { getByTestId } = render(
      +      <Harness busy onCancel={onCancel} onDrain={onDrain} onQueue={onQueue} onSubmit={vi.fn()} queued={['queued-1']} />
      +    )
      +    const editor = getByTestId('editor')
      +
      +    await act(async () => {
      +      editor.textContent = 'urgent follow-up'
      +      fireEvent.keyDown(editor, { key: 'Enter' })
      +    })
      +
      +    expect(onQueue).toHaveBeenCalledWith('urgent follow-up')
      +    expect(onDrain).not.toHaveBeenCalled()
      +    expect(onCancel).not.toHaveBeenCalled()
      +  })
      +
      +  it('treats an empty Enter while busy as a no-op (never an accidental Stop)', async () => {
      +    const onCancel = vi.fn()
      +    const onSubmit = vi.fn()
      +    const onQueue = vi.fn()
      +    const { getByTestId } = render(
      +      <Harness busy onCancel={onCancel} onDrain={vi.fn()} onQueue={onQueue} onSubmit={onSubmit} />
      +    )
      +    const editor = getByTestId('editor')
      +
      +    await act(async () => {
      +      editor.textContent = ''
      +      fireEvent.keyDown(editor, { key: 'Enter' })
      +    })
      +
      +    expect(onCancel).not.toHaveBeenCalled()
      +    expect(onSubmit).not.toHaveBeenCalled()
      +    expect(onQueue).not.toHaveBeenCalled()
      +  })
      +
      +  it('drains the next queued prompt on Enter when idle with a truly empty editor', async () => {
      +    const onDrain = vi.fn()
      +    const onSubmit = vi.fn()
      +    const { getByTestId } = render(
      +      <Harness onCancel={vi.fn()} onDrain={onDrain} onQueue={vi.fn()} onSubmit={onSubmit} queued={['queued-1']} />
      +    )
      +    const editor = getByTestId('editor')
      +
      +    await act(async () => {
      +      editor.textContent = ''
      +      fireEvent.keyDown(editor, { key: 'Enter' })
      +    })
      +
      +    expect(onDrain).toHaveBeenCalledTimes(1)
      +    expect(onSubmit).not.toHaveBeenCalled()
      +  })
      +})
    • apps/desktop/src/app/chat/composer/index.tsx
      @@ -814,7 +814,16 @@ export function ChatBar({
           if (event.key === 'Enter' && !event.shiftKey) {
             event.preventDefault()
       
      -      if (!busy && !hasComposerPayload && queuedPrompts.length > 0) {
      +      // Decide from the DOM, not React state. `hasComposerPayload` is derived
      +      // from the AUI composer state, which lags the latest keystroke by a
      +      // render, so on fast typing / IME the just-typed text isn't in state yet.
      +      // Without the live read, a real message typed while prompts are queued
      +      // would drain the queue instead of sending. submitDraft() re-syncs and
      +      // sends the live editor text.
      +      const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
      +      const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
      +
      +      if (!busy && !hasLivePayload && queuedPrompts.length > 0) {
               void drainNextQueued()
       
               return
      @@ -822,7 +831,10 @@ export function ChatBar({
       
             // Empty Enter while busy is a no-op — interrupting is explicit (Stop/Esc),
             // never a stray Enter after sending. With a payload, submitDraft queues it.
      -      if (busy && !hasComposerPayload) {
      +      // Gate on the live DOM payload (not the render-lagged composer state) so a
      +      // message typed fast / via IME while busy still reaches submitDraft() and
      +      // gets queued instead of being mistaken for an empty Enter.
      +      if (busy && !hasLivePayload) {
               return
             }
       
      @@ -1212,6 +1224,26 @@ export function ChatBar({
         }, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps
       
         const submitDraft = () => {
      +    // Source the text from the DOM editor, not React state. The AUI composer
      +    // state (`draft`) and the derived `hasComposerPayload` lag the DOM by a
      +    // render, so on fast typing or IME composition the final keystroke(s) may
      +    // not have synced yet — reading state here drops the message (Enter looks
      +    // like it does nothing; typing a trailing space only "fixes" it because the
      +    // extra input event forces a state sync). draftRef is updated on every
      +    // input event; refresh it from the editor once more to also cover an
      +    // in-flight keystroke that hasn't fired its input event yet.
      +    const editor = editorRef.current
      +    if (editor) {
      +      const domText = composerPlainText(editor)
      +      if (domText !== draftRef.current) {
      +        draftRef.current = domText
      +        aui.composer().setText(domText)
      +      }
      +    }
      +
      +    const text = draftRef.current
      +    const payloadPresent = text.trim().length > 0 || attachments.length > 0
      +
           if (queueEdit) {
             exitQueuedEdit('save')
           } else if (busy) {
      @@ -1222,12 +1254,12 @@ 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(text.trim())) {
      +        const submitted = text
               triggerHaptic('submit')
               clearDraft()
               void onSubmit(submitted)
      -      } else if (hasComposerPayload) {
      +      } else if (payloadPresent) {
               queueCurrentDraft()
             } else {
               // Stop button (the only way to reach here while busy with an empty
      @@ -1235,10 +1267,10 @@ export function ChatBar({
               triggerHaptic('cancel')
               void Promise.resolve(onCancel())
             }
      -    } else if (!hasComposerPayload && queuedPrompts.length > 0) {
      +    } else if (!payloadPresent && queuedPrompts.length > 0) {
             void drainNextQueued()
      -    } else if (draft.trim() || attachments.length > 0) {
      -      const submitted = draft
      +    } else if (payloadPresent) {
      +      const submitted = text
             triggerHaptic('submit')
             resetBrowseState(sessionId)
             clearDraft()

2 PRs closed without merging

From the close comments: closed without comment 1 · done elsewhere 1

  • #44304 fix(desktop): prevent Korean IME final syllable drop on Enter submit (#44278) · closed without comment
  • #44332 fix(desktop): prevent Korean IME final syllable drop on Enter submit (#44278) · done elsewhere → 0caa237, 8b84d82, #38333, #39639
Closed issues · 1
  • Closed#44278Desktop: Korean IME drops final syllable on Enter submit
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#39639
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
10 Jun 2026

For your agent

The same facts, typed.