From 8b84d82227a3b637a26ba5f4f41bb08281e2ab84 Mon Sep 17 00:00:00 2001 From: xxxigm <54813621+xxxigm@users.noreply.github.com> Date: Wed, 10 Jun 2026 07:51:23 +0700 Subject: [PATCH] fix(desktop): send on Enter from live editor text, not stale composer state (#39639) * fix(desktop): send on Enter from live editor text, not stale composer state Pressing Enter often did nothing (~90% with IME / fast typing); adding a trailing space "fixed" it. The composer's submit path read the draft from the AUI composer state (`useAuiState(s => s.composer.text)`) and the derived `hasComposerPayload`, both of which lag the contentEditable DOM by a render. On fast typing or IME composition the final keystroke(s) weren't in state yet, so `submitDraft()` saw an empty draft and dropped the message. A trailing space only worked around it by forcing an extra input event that flushed the state. submitDraft() now refreshes draftRef from the editor node and submits/queues based on the live DOM text, and the Enter handler decides the queue-drain vs submit branch from the DOM too. draftRef is already synced on every input event, so this just closes the in-flight-keystroke gap. Fixes #39630. Also addresses the "typing + Enter does nothing" reports in #39623. * test(desktop): cover Enter-submit from live editor text (#39630) Pin the contract that the composer's Enter path reads the live DOM editor text, not the render-lagged composer state: a just-typed message sends even when state hasn't synced; while busy it queues (never drains the queue or cancels); an empty Enter while busy is a no-op; and an empty idle Enter drains the next queued prompt. Faithful DOM-event repro mirroring handleEditorKeyDown + submitDraft. --- .../composer/enter-submit-dom-race.test.tsx | 189 ++++++++++++++++++ apps/desktop/src/app/chat/composer/index.tsx | 48 ++++- 2 files changed, 229 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx diff --git a/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx b/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx new file mode 100644 index 00000000000..76fdf79f809 --- /dev/null +++ b/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(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) => { + 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 ( +
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( + + ) + 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( + + ) + 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( + + ) + 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( + + ) + const editor = getByTestId('editor') + + await act(async () => { + editor.textContent = '' + fireEvent.keyDown(editor, { key: 'Enter' }) + }) + + expect(onDrain).toHaveBeenCalledTimes(1) + expect(onSubmit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 62a5f6b6b79..d8b06a68d37 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/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 } @@ -1227,6 +1239,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) { @@ -1237,12 +1269,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 @@ -1250,10 +1282,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()