Desktop send button missing with committed IME text input
Cluster 39614. Data as of
Nothing open on GitHub. Fix merged in #40210 on 6 Jun 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #40210. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #40210. That is separate from the fix event. - Still open
- Nothing open on GitHub.
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.
Open issues0
Open PRs0
Fixes on record
-
#40210 fix(desktop): show send button for committed IME text (Chinese/Japanese/Korean)
files:
apps/desktop/src/app/chat/composer/ime-composition-dom-repro.test.tsx,apps/desktop/src/app/chat/composer/index.tsxShow the diff
-
apps/desktop/src/app/chat/composer/ime-composition-dom-repro.test.tsx@@ -0,0 +1,108 @@ +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { useRef, useState } from 'react' +import { afterEach, describe, expect, it } 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 composer text wiring for IME input, driven +// through REAL DOM composition + input events on a contentEditable. +// +// Regression repro for #39614: typing committed multi-character IME text (e.g. +// Chinese "你好") used to leave the send button hidden. The input events fired +// during composition carry uncommitted preedit text and are intentionally +// skipped; Chromium then does NOT reliably emit a trailing input event after +// compositionend on Windows IMEs, so the finalized text never reached composer +// state and `hasPayload` stayed false until an unrelated edit forced a sync. +// The fix flushes the live DOM text in onCompositionEnd. +function Harness({ onPayload }: { onPayload: (hasPayload: boolean) => void }) { + const editorRef = useRef<HTMLDivElement>(null) + const composingRef = useRef(false) + const draftRef = useRef('') + const [draft, setDraft] = useState('') + + const flushEditorToDraft = (editor: HTMLDivElement) => { + const next = editor.textContent ?? '' + + if (next !== draftRef.current) { + draftRef.current = next + setDraft(next) + } + } + + onPayload(draft.trim().length > 0) + + return ( + <div + contentEditable + data-testid="editor" + onCompositionEnd={event => { + composingRef.current = false + flushEditorToDraft(event.currentTarget) + }} + onCompositionStart={() => { + composingRef.current = true + }} + onInput={event => { + if (composingRef.current) { + return + } + + flushEditorToDraft(event.currentTarget) + }} + ref={editorRef} + suppressContentEditableWarning + /> + ) +} + +describe('composer IME composition — send button visibility (#39614)', () => { + it('shows the send button after committing CJK text without a trailing edit', async () => { + let hasPayload = false + const { getByTestId } = render(<Harness onPayload={p => (hasPayload = p)} />) + const editor = getByTestId('editor') + + // Compose "你好" the way a Windows Chinese IME does: compositionstart, then + // input events carrying uncommitted preedit text, then compositionend with + // the committed text already in the DOM — and crucially NO input event + // afterwards. + await act(async () => { + fireEvent.compositionStart(editor) + editor.textContent = '你' + fireEvent.input(editor) + editor.textContent = '你好' + fireEvent.input(editor) + fireEvent.compositionEnd(editor) + }) + + // Before the fix this was false (button hidden) until a further edit. + expect(hasPayload).toBe(true) + expect(editor.textContent).toBe('你好') + }) + + it('also covers Japanese/Korean and any IME-composed script', async () => { + let hasPayload = false + const { getByTestId } = render(<Harness onPayload={p => (hasPayload = p)} />) + const editor = getByTestId('editor') + + for (const committed of ['こんにちは', '안녕하세요']) { + await act(async () => { + fireEvent.compositionStart(editor) + editor.textContent = committed + fireEvent.input(editor) + fireEvent.compositionEnd(editor) + }) + + expect(hasPayload).toBe(true) + + // Clear for the next script. + await act(async () => { + editor.textContent = '' + fireEvent.input(editor) + }) + expect(hasPayload).toBe(false) + } + }) +}) -
apps/desktop/src/app/chat/composer/index.tsx@@ -549,16 +549,10 @@ export function ChatBar({ } }, [trigger]) - const handleEditorInput = (event: FormEvent<HTMLDivElement>) => { - // During IME composition the DOM contains uncommitted preedit text - // mixed with real content. Skip state writes — compositionend will - // deliver the finalized text via a clean input event. - if (composingRef.current) { - return - } - - const editor = event.currentTarget - + // Pull the live contentEditable text into draftRef + the AUI composer state + // (which drives `hasComposerPayload` → the send button). Shared by the input + // and compositionend paths so committed IME text reaches state through either. + const flushEditorToDraft = (editor: HTMLDivElement) => { if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') { editor.replaceChildren() } @@ -573,6 +567,17 @@ export function ChatBar({ window.setTimeout(refreshTrigger, 0) } + const handleEditorInput = (event: FormEvent<HTMLDivElement>) => { + // During IME composition the DOM contains uncommitted preedit text + // mixed with real content. Skip state writes — compositionend flushes + // the finalized text (see onCompositionEnd). + if (composingRef.current) { + return + } + + flushEditorToDraft(event.currentTarget) + } + const triggerAdapter: Unstable_TriggerAdapter | null = trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null @@ -1208,8 +1213,17 @@ export function ChatBar({ data-placeholder={placeholder} data-slot={RICH_INPUT_SLOT} onBlur={() => window.setTimeout(closeTrigger, 80)} - onCompositionEnd={() => { + onCompositionEnd={event => { composingRef.current = false + + // The input events fired *during* composition were skipped (they + // carried uncommitted preedit text), and Chromium does NOT reliably + // emit a trailing input event after compositionend on Windows IMEs. + // Without flushing here, committed multi-character IME input (e.g. + // Chinese "你好", Japanese, Korean) never reaches composer state, so + // `hasComposerPayload` stays false and the send button stays hidden + // until an unrelated edit forces a sync (#39614). + flushEditorToDraft(event.currentTarget) }} onCompositionStart={() => { composingRef.current = true
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#39650 fix(desktop): show send button for committed IME text (Chinese/Japanese/Korean) · merged via another change →
#40210
Closed issues · 1
- Closed#39614[Bug]: Chinese Character Send Button Disappear Bug
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.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means 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 LabAt a glance
- Merged PRs
- #40210
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 6 Jun 2026