Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx
Original file line number Diff line number Diff line change
@@ -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()
})
})
48 changes: 40 additions & 8 deletions apps/desktop/src/app/chat/composer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -814,15 +814,27 @@ 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
}

// 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
}

Expand Down Expand Up @@ -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) {
Expand All @@ -1222,23 +1254,23 @@ 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
// composer — empty Enter is short-circuited in the keydown handler).
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()
Expand Down
Loading