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
51 changes: 48 additions & 3 deletions apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ function Harness({
onSubmit,
onQueue,
onCancel,
onDrain
onDrain,
onSendNow
}: {
busy?: boolean
disabled?: boolean
Expand All @@ -38,6 +39,7 @@ function Harness({
onQueue: (text: string) => void
onCancel: () => void
onDrain: () => void
onSendNow?: (id: string) => void
}) {
const editorRef = useRef<HTMLDivElement>(null)
const draftRef = useRef('')
Expand Down Expand Up @@ -103,6 +105,12 @@ function Harness({
}

if (busy && !hasLivePayload) {
const head = queued[0]

if (head) {
onSendNow?.(head)
}

return
}

Expand Down Expand Up @@ -167,13 +175,21 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
expect(onCancel).not.toHaveBeenCalled()
})

it('treats an empty Enter while busy as a no-op (never an accidental Stop)', async () => {
it('treats an empty Enter while busy with nothing queued as a no-op (never an accidental Stop)', async () => {
const onCancel = vi.fn()
const onSubmit = vi.fn()
const onQueue = vi.fn()
const onSendNow = vi.fn()

const { getByTestId } = render(
<Harness busy onCancel={onCancel} onDrain={vi.fn()} onQueue={onQueue} onSubmit={onSubmit} />
<Harness
busy
onCancel={onCancel}
onDrain={vi.fn()}
onQueue={onQueue}
onSendNow={onSendNow}
onSubmit={onSubmit}
/>
)

const editor = getByTestId('editor')
Expand All @@ -186,6 +202,35 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
expect(onCancel).not.toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
expect(onQueue).not.toHaveBeenCalled()
expect(onSendNow).not.toHaveBeenCalled()
})

it('double-send: an empty Enter while busy with a queued turn sends that turn now', async () => {
const onCancel = vi.fn()
const onSendNow = vi.fn()

const { getByTestId } = render(
<Harness
busy
onCancel={onCancel}
onDrain={vi.fn()}
onQueue={vi.fn()}
onSendNow={onSendNow}
onSubmit={vi.fn()}
queued={['queued-1', 'queued-2']}
/>
)

const editor = getByTestId('editor')

await act(async () => {
editor.textContent = ''
fireEvent.keyDown(editor, { key: 'Enter' })
})

// Head of the queue, and NOT a bare cancel — send-now promotes + interrupts.
expect(onSendNow).toHaveBeenCalledWith('queued-1')
expect(onCancel).not.toHaveBeenCalled()
})

it('drains the next queued prompt on Enter when idle with a truly empty editor', async () => {
Expand Down
19 changes: 14 additions & 5 deletions apps/desktop/src/app/chat/composer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -724,12 +724,21 @@ export function ChatBar({
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.
// 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.
// Empty Enter while busy. With prompts queued this is the double-send:
// the first Enter put the words in the queue, a second sends them now
// (promote + interrupt + drain on settle), mirroring the idle empty-Enter
// drain above. With nothing queued it stays a no-op — interrupting is
// explicit (Stop/Esc), never a stray Enter after sending. 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) {
const head = queuedPrompts.find(entry => entry.id !== queueEdit?.entryId)

if (head) {
sendQueuedNow(head.id)
}

return
}

Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/app/chat/composer/queue-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { ArrowUp, iconSize, Pencil, Trash2 } from '@/lib/icons'
import { CornerDownLeft, iconSize, Pencil, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { QueuedPromptEntry } from '@/store/composer-queue'

Expand Down Expand Up @@ -103,7 +103,7 @@ export function QueuePanel({
type="button"
variant="ghost"
>
<ArrowUp className={iconSize.xs} />
<CornerDownLeft className={iconSize.xs} />
</Button>
</Tip>
<Tip label={c.queueDelete}>
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
IconCommand as Command,
IconCopy as Copy,
IconCopy as CopyIcon,
IconCornerDownLeft as CornerDownLeft,
IconCpu as Cpu,
IconCreditCard as CreditCard,
IconDownload as Download,
Expand Down Expand Up @@ -155,6 +156,7 @@ export {
Command,
Copy,
CopyIcon,
CornerDownLeft,
Cpu,
CreditCard,
Download,
Expand Down
Loading